diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md
index af6697373b4..c9a3e95df0d 100644
--- a/news/changelog-1.11.md
+++ b/news/changelog-1.11.md
@@ -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.
diff --git a/src/project/types/website/listing/website-listing-read.ts b/src/project/types/website/listing/website-listing-read.ts
index fd4ac12a9c1..1737deabcf7 100644
--- a/src/project/types/website/listing/website-listing-read.ts
+++ b/src/project/types/website/listing/website-listing-read.ts
@@ -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}`,
@@ -499,6 +501,37 @@ function emptyDiv(height?: string) {
}> `;
}
+// 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(
+ `]*)>(\\s*)${escapeRegExp(placeholder)}(\\s*)`,
+ );
+ 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,
+ () => `${before}${emptyDiv(height)}${after}`,
+ );
+}
+
+function escapeRegExp(str: string) {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
function descriptionPlaceholder(file?: string, maxLength?: number): string {
return file ? `` : "";
}
diff --git a/tests/docs/listings-thumbnail-link/.gitignore b/tests/docs/listings-thumbnail-link/.gitignore
new file mode 100644
index 00000000000..812977e5f74
--- /dev/null
+++ b/tests/docs/listings-thumbnail-link/.gitignore
@@ -0,0 +1,4 @@
+/.quarto/
+_site/
+
+**/*.quarto_ipynb
diff --git a/tests/docs/listings-thumbnail-link/_quarto.yml b/tests/docs/listings-thumbnail-link/_quarto.yml
new file mode 100644
index 00000000000..67136530fe0
--- /dev/null
+++ b/tests/docs/listings-thumbnail-link/_quarto.yml
@@ -0,0 +1,7 @@
+project:
+ type: website
+ resources:
+ - placeholder.png
+
+website:
+ title: "Listing thumbnail link"
diff --git a/tests/docs/listings-thumbnail-link/index.qmd b/tests/docs/listings-thumbnail-link/index.qmd
new file mode 100644
index 00000000000..159bb2e5918
--- /dev/null
+++ b/tests/docs/listings-thumbnail-link/index.qmd
@@ -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.
diff --git a/tests/docs/listings-thumbnail-link/placeholder.png b/tests/docs/listings-thumbnail-link/placeholder.png
new file mode 100644
index 00000000000..b2c318cd123
Binary files /dev/null and b/tests/docs/listings-thumbnail-link/placeholder.png differ
diff --git a/tests/docs/listings-thumbnail-link/placeholder.qmd b/tests/docs/listings-thumbnail-link/placeholder.qmd
new file mode 100644
index 00000000000..8b1a95aac64
--- /dev/null
+++ b/tests/docs/listings-thumbnail-link/placeholder.qmd
@@ -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.
diff --git a/tests/docs/listings-thumbnail-link/posts/body-image/figure.png b/tests/docs/listings-thumbnail-link/posts/body-image/figure.png
new file mode 100644
index 00000000000..38ae4055da6
Binary files /dev/null and b/tests/docs/listings-thumbnail-link/posts/body-image/figure.png differ
diff --git a/tests/docs/listings-thumbnail-link/posts/body-image/index.qmd b/tests/docs/listings-thumbnail-link/posts/body-image/index.qmd
new file mode 100644
index 00000000000..c2b910ac701
--- /dev/null
+++ b/tests/docs/listings-thumbnail-link/posts/body-image/index.qmd
@@ -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.
+
+
diff --git a/tests/docs/listings-thumbnail-link/posts/front-matter-image/index.qmd b/tests/docs/listings-thumbnail-link/posts/front-matter-image/index.qmd
new file mode 100644
index 00000000000..35ace7968ad
--- /dev/null
+++ b/tests/docs/listings-thumbnail-link/posts/front-matter-image/index.qmd
@@ -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.
diff --git a/tests/docs/listings-thumbnail-link/posts/front-matter-image/thumb.png b/tests/docs/listings-thumbnail-link/posts/front-matter-image/thumb.png
new file mode 100644
index 00000000000..f78ff28c97e
Binary files /dev/null and b/tests/docs/listings-thumbnail-link/posts/front-matter-image/thumb.png differ
diff --git a/tests/docs/listings-thumbnail-link/posts/no-image/index.qmd b/tests/docs/listings-thumbnail-link/posts/no-image/index.qmd
new file mode 100644
index 00000000000..965352a6b0a
--- /dev/null
+++ b/tests/docs/listings-thumbnail-link/posts/no-image/index.qmd
@@ -0,0 +1,6 @@
+---
+title: "Post without an image"
+date: 2026-01-03
+---
+
+Text without an image.
diff --git a/tests/smoke/site/render-listing-thumbnail-link.test.ts b/tests/smoke/site/render-listing-thumbnail-link.test.ts
new file mode 100644
index 00000000000..c73a9aa79ff
--- /dev/null
+++ b/tests/smoke/site/render-listing-thumbnail-link.test.ts
@@ -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();
+ },
+});