From cbcb36f46f107d51e5a32a0cf796b438e7e86ed1 Mon Sep 17 00:00:00 2001 From: Nell Date: Sun, 18 Oct 2026 16:23:56 +1100 Subject: [PATCH] Hide the empty thumbnail link of listing items without an image The default listing template wraps every item thumbnail in a link to the item. A post with no preview image, in a listing with no image-placeholder, gets an empty placeholder div in that link, so the page has one link per such post with no accessible name that is still a Tab stop. axe-core reports it as link-name (WCAG 2.4.4, 4.1.2). The template cannot tell at render time whether an image will be found, since the placeholder is only resolved once the post is rendered. So when the placeholder is replaced by the empty div and sits directly inside a link, that link now gets aria-hidden="true" and tabindex="-1". The markup is otherwise unchanged, so the layout stays the same, and links that do show an image are not touched. The title link next to the thumbnail goes to the same page. - Resolve the empty placeholder through replaceWithEmptyDiv() in website-listing-read.ts, which hides a link that wraps only the placeholder; the other attributes of the link are kept - Add a smoke test rendering a default listing with a post whose image comes from the front matter, one whose image is found in the rendered body, and one with no image, with and without image-placeholder Fixes #15066 --- news/changelog-1.11.md | 1 + .../website/listing/website-listing-read.ts | 41 +++++++++++++-- tests/docs/listings-thumbnail-link/.gitignore | 4 ++ .../docs/listings-thumbnail-link/_quarto.yml | 7 +++ tests/docs/listings-thumbnail-link/index.qmd | 10 ++++ .../listings-thumbnail-link/placeholder.png | Bin 0 -> 74 bytes .../listings-thumbnail-link/placeholder.qmd | 11 ++++ .../posts/body-image/figure.png | Bin 0 -> 74 bytes .../posts/body-image/index.qmd | 8 +++ .../posts/front-matter-image/index.qmd | 8 +++ .../posts/front-matter-image/thumb.png | Bin 0 -> 74 bytes .../posts/no-image/index.qmd | 6 +++ .../render-listing-thumbnail-link.test.ts | 48 ++++++++++++++++++ 13 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 tests/docs/listings-thumbnail-link/.gitignore create mode 100644 tests/docs/listings-thumbnail-link/_quarto.yml create mode 100644 tests/docs/listings-thumbnail-link/index.qmd create mode 100644 tests/docs/listings-thumbnail-link/placeholder.png create mode 100644 tests/docs/listings-thumbnail-link/placeholder.qmd create mode 100644 tests/docs/listings-thumbnail-link/posts/body-image/figure.png create mode 100644 tests/docs/listings-thumbnail-link/posts/body-image/index.qmd create mode 100644 tests/docs/listings-thumbnail-link/posts/front-matter-image/index.qmd create mode 100644 tests/docs/listings-thumbnail-link/posts/front-matter-image/thumb.png create mode 100644 tests/docs/listings-thumbnail-link/posts/no-image/index.qmd create mode 100644 tests/smoke/site/render-listing-thumbnail-link.test.ts diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 839d7ca3606..65de82c29b6 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -33,6 +33,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) ## Formats 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 0000000000000000000000000000000000000000..b2c318cd12367cc84c788a738cc9283dd5db7d09 GIT binary patch literal 74 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1SGw4HSYi^K~ERQkcv6U92^#nqJ})WoD4iy WqZk)dN0o|qJ})WoD3dU WqZmK`;*|$VGI+ZBxvX