Skip to content
Merged
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
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ All changes included in 1.11:
- ([#15019](https://github.com/quarto-dev/quarto-cli/issues/15019)): Fix screen reader announcements in `revealjs` presentations including incremental content before it is revealed and repeating slides and fragments when browser history is enabled. (author: @jooyoungseo)
- ([#15030](https://github.com/quarto-dev/quarto-cli/pull/15030)): Fix `axe` checks in `revealjs` presentations hiding the current slide from screen readers when moving to another slide while the scan runs.
- ([#15033](https://github.com/quarto-dev/quarto-cli/issues/15033)): Fix screen reader announcements in `revealjs` presentations reading the CSS inside Mermaid diagrams, omitting image alternative text, and reading the contents of `role="img"` graphics instead of their accessible names. (author: @jooyoungseo)
- ([#15066](https://github.com/quarto-dev/quarto-cli/issues/15066)): Fix default listings rendering an empty, focusable thumbnail link for posts without a preview image, flagged by axe-core (`link-name`); the link is now hidden from assistive technology and taken out of the tab order. (author: @chanellebingham)
- ([#15079](https://github.com/quarto-dev/quarto-cli/issues/15079)): Fix keyboard focus in `revealjs` presentations moving to the page when coming back to the presentation's browser tab, which took readers off the link or chart they were on and made screen readers stop passing the arrow keys to the presentation. (author: @jooyoungseo)
- ([#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.

Expand Down
41 changes: 37 additions & 4 deletions src/project/types/website/listing/website-listing-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,16 +465,18 @@ export function completeListingItems(
);
} else {
debug(`[listing] using empty div: ${docAbsPath}`);
fileContents = fileContents.replace(
fileContents = replaceWithEmptyDiv(
fileContents,
imgPlaceholder,
emptyDiv(imgHeight),
imgHeight,
);
}
} else {
debug(`[listing] does not exist: ${docAbsPath}`);
fileContents = fileContents.replace(
fileContents = replaceWithEmptyDiv(
fileContents,
imgPlaceholder,
emptyDiv(imgHeight),
imgHeight,
);
warning(
`Unable to read listing preview image from ${docRelativePath}`,
Expand All @@ -499,6 +501,37 @@ function emptyDiv(height?: string) {
}>&nbsp;</div>`;
}

// The default listing template places the image placeholder inside a link
// to the item. When no image is found, that link wraps only the empty div,
// so it has no accessible name but is still a tab stop (#15066). Keep the
// link for the layout, but hide it from assistive technology and take it
// out of the tab order; the title link next to it goes to the same page.
function replaceWithEmptyDiv(
contents: string,
placeholder: string,
height?: string,
) {
const linkedPlaceholder = new RegExp(
`<a\\b([^>]*)>(\\s*)${escapeRegExp(placeholder)}(\\s*)</a>`,
);
const match = linkedPlaceholder.exec(contents);
if (!match) {
return contents.replace(placeholder, emptyDiv(height));
}
const [link, attrs, before, after] = match;
const hiddenAttrs = attrs +
(/\saria-hidden=/.test(attrs) ? "" : ' aria-hidden="true"') +
(/\stabindex=/.test(attrs) ? "" : ' tabindex="-1"');
return contents.replace(
link,
() => `<a${hiddenAttrs}>${before}${emptyDiv(height)}${after}</a>`,
);
}

function escapeRegExp(str: string) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function descriptionPlaceholder(file?: string, maxLength?: number): string {
return file ? `<!-- desc(5A0113B34292)[max=${maxLength}]:${file} -->` : "";
}
Expand Down
4 changes: 4 additions & 0 deletions tests/docs/listings-thumbnail-link/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/.quarto/
_site/

**/*.quarto_ipynb
7 changes: 7 additions & 0 deletions tests/docs/listings-thumbnail-link/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
project:
type: website
resources:
- placeholder.png

website:
title: "Listing thumbnail link"
10 changes: 10 additions & 0 deletions tests/docs/listings-thumbnail-link/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: "Posts"
listing:
contents: posts
type: default
sort: "title"
---

A default listing with no `image-placeholder`. The post without an image
gets an empty placeholder in its thumbnail link.
Binary file added tests/docs/listings-thumbnail-link/placeholder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions tests/docs/listings-thumbnail-link/placeholder.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
title: "Posts with a placeholder image"
listing:
contents: posts
type: default
sort: "title"
image-placeholder: placeholder.png
---

The same listing with an `image-placeholder`. The post without an image
shows the placeholder image in its thumbnail link.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions tests/docs/listings-thumbnail-link/posts/body-image/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Post with an image in the body"
date: 2026-01-02
---

Text with an image in the body, found once the post is rendered.

![A figure](figure.png)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
title: "Post with an image in the front matter"
date: 2026-01-01
image: thumb.png
image-alt: "A red square"
---

Text with an image set in the front matter.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions tests/docs/listings-thumbnail-link/posts/no-image/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: "Post without an image"
date: 2026-01-03
---

Text without an image.
48 changes: 48 additions & 0 deletions tests/smoke/site/render-listing-thumbnail-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* render-listing-thumbnail-link.test.ts
*
* Copyright (C) 2026 Posit Software, PBC
*/
import { join } from "../../../src/deno_ral/path.ts";
import { removeIfExists } from "../../../src/core/path.ts";
import { testQuartoCmd } from "../../test.ts";
import { docs } from "../../utils.ts";
import { ensureHtmlElements, noErrorsOrWarnings } from "../../verify.ts";

// The default listing template wraps the thumbnail of every post in a link
// to the post. When a post has no preview image and the listing sets no
// image-placeholder, the thumbnail is an empty div, which left a link with
// no accessible name in the tab order (#15066). That link is now hidden
// from assistive technology and taken out of the tab order; thumbnails that
// show an image keep a plain link.
const projectDir = docs("listings-thumbnail-link");
const outputDir = join(projectDir, "_site");

testQuartoCmd("render", [projectDir], [
noErrorsOrWarnings,
ensureHtmlElements(join(outputDir, "index.html"), [
// image from the front matter: a plain link
'div.thumbnail a:not([aria-hidden]):not([tabindex]) img[src$="thumb.png"]',
// image found in the rendered post: a plain link
'div.thumbnail a:not([aria-hidden]):not([tabindex]) img[src$="figure.png"]',
// no image: the empty placeholder link is hidden and not a tab stop
'div.thumbnail a[aria-hidden="true"][tabindex="-1"] div.listing-item-img-placeholder',
], [
'div.thumbnail a:not([tabindex="-1"]) div.listing-item-img-placeholder',
]),
// with image-placeholder every post shows an image, so no link is hidden
ensureHtmlElements(join(outputDir, "placeholder.html"), [
'div.thumbnail a:not([aria-hidden]):not([tabindex]) img[src$="placeholder.png"]',
], [
"div.listing-item-img-placeholder",
"div.thumbnail a[aria-hidden]",
"div.thumbnail a[tabindex]",
]),
], {
name: "Listing thumbnail link of a post without an image is not a tab stop",
teardown: () => {
removeIfExists(outputDir);
removeIfExists(join(projectDir, ".quarto"));
return Promise.resolve();
},
});
Loading