Skip to content

Hide the empty thumbnail link of listing items without an image - #15099

Merged
cderv merged 3 commits into
quarto-dev:mainfrom
chanellebingham:fix-15066-listing-empty-thumbnail-link
Oct 20, 2026
Merged

cderv merged 3 commits into
quarto-dev:mainfrom
chanellebingham:fix-15066-listing-empty-thumbnail-link

Conversation

@chanellebingham

Copy link
Copy Markdown
Contributor

Description

In a default listing, a post with no preview image gets an empty thumbnail link. item-default.ejs.md wraps the thumbnail of every item in <a href="…" class="no-external">, and when the post has no image and the listing sets no image-placeholder, emptyDiv() in website-listing-read.ts fills that link with <div class="listing-item-img-placeholder">&nbsp;</div>. The link has no accessible name but is still a Tab stop, so keyboard and screen reader users meet one unnamed link per post before its title link. axe-core reports it as link-name.

The template only knows about an image the post declares in its front matter (image). Otherwise it emits a placeholder comment, and completeListingItems() resolves it once the post has been rendered: a preview image found in the rendered page (.preview-image, a feature/cover/thumbnail image, or the first local image), the listing's image-placeholder, or the empty div. So the fix sits at that last step. When the placeholder becomes the empty div and sits directly inside a link, the link gets aria-hidden="true" and tabindex="-1". Its other attributes are kept and 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.

Before:

<div class="thumbnail"><a href="./posts/no-image/index.html" class="no-external">

<div class="listing-item-img-placeholder card-img-top" >&nbsp;</div>

</a></div>

After:

<div class="thumbnail"><a href="./posts/no-image/index.html" class="no-external" aria-hidden="true" tabindex="-1">

<div class="listing-item-img-placeholder card-img-top" >&nbsp;</div>

</a></div>

I hid the link rather than removing it because that keeps the DOM identical for CSS and custom templates, and it is one of the two outcomes the issue asks for. A custom template that puts the placeholder inside a link next to other content is left alone, since that link has content of its own.

The new smoke test renders a default listing with three posts (image in the front matter, image found in the rendered body, no image), once without image-placeholder (index.qmd) and once with it (placeholder.qmd). On main it fails with Required DOM Element div.thumbnail a[aria-hidden="true"][tabindex="-1"] div.listing-item-img-placeholder is missing; with this change it passes.

Run on Windows 11 with the dev build (configure.cmd):

.\run-tests.ps1 smoke/site/render-listing-thumbnail-link.test.ts
ok | 1 passed | 0 failed (3s)

.\run-tests.ps1 --agent smoke/site/render-listings.test.ts smoke/site/render-listing-contents-file.test.ts smoke/site/render-listing-feed-image.test.ts smoke/site/render-blog.test.ts smoke/site/render-listing-thumbnail-link.test.ts
ok | 7 passed | 0 failed (39s)

The smoke-all listing documents under listings/image-lazy-loading/blog/, 2026/10/12/issue-15022, 2024/10/23/issue-10829, 2024/12/03/issue-11580, 2025/04/01/issue-8238 and website/llms-txt-listing pass as well (11 passed). 2024/10/28/issue-10311 fails on this machine with and without the change because its posts need a Python kernel that is not set up here.

axe-core 4.14 on the rendered index.html: the link-name finding on .thumbnail > .no-external[href="./posts/no-image/index.html"] is gone, and no aria-hidden-focus finding appears. The link-name and image-alt findings that remain on thumbnails that do show an image come from those <img> having no alt, which is #15038 / #7028 and not changed here.

The axe-core check ran in headless Chromium (Playwright's chromium_headless_shell), not with a screen reader. deno fmt --check on website-listing-read.ts flags lines 18–21 (an import block); that complaint exists on main unchanged and I left it alone. The added code is fmt-clean and deno lint passes.

Open questions

  1. Hide vs remove: the issue accepts either. Hiding keeps the DOM identical; removing the <a> would be a small change in the same helper if you prefer no link at all.
  2. The helper hides any link that wraps only the placeholder, so a table listing with field-links: [image] gets the same treatment. The default table listing uses a <span>, so default output is unchanged. Fine, or should it be limited to the default template?
  3. The thumbnails that do show an image still have no alt (a11y: quarto create project blog scaffold has images with no alt text and icon-only navbar links with no name #15038, Add support for alt (and maybe title) for placeholder-image in listings #7028). Left untouched on purpose; happy to follow up separately.

Fixes #15066

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes
  • updated the appropriate changelog in the PR
  • ensured the present test suite passes (ran the listing, blog and smoke-all listing tests listed above; the full suite needs R, Python and Julia which I have not set up)
  • added new tests
  • created a separate documentation PR in Quarto's website repo and linked it to this PR (not applicable: no documented behaviour changes)
AI-assisted PR
  • AI tool used: Claude Code
  • Codebase grounding: local clone
  • Human review: I have reviewed, tested, and verified the AI-generated content before submitting.

Note: autonomous AI agents submitting PRs without human oversight are not permitted — see the Code of Conduct.

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 quarto-dev#15066
@posit-snyk-bot

posit-snyk-bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Both this branch and main (quarto-dev#15080) added an entry at the same place in
news/changelog-1.11.md. Kept both, in issue-number order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCs32K1rV7d8ZZc9jRAAcV
@chanellebingham

Copy link
Copy Markdown
Contributor Author

Merged main to resolve a conflict in news/changelog-1.11.md: main's #15080 entry landed in the same spot as this one, so I kept both, in issue-number order. No other changes.

@cderv cderv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. The new test fails on main with the assertion you quote and passes with the change, and the other listing tests still pass.

About your questions:

  1. Hiding is fine. The empty thumbnail stays a mouse target like the ones showing an image, and the markup does not change for themes and custom templates.
  2. Fine as is. With a table listing using field-links: [image, title], the empty image link is hidden and the title link stays reachable. The only case where it matters is field-links: [image] without any other linked field, where the hidden link was the only link to the post. That one was already not usable before (link without a name), so I don't think it is worth special casing here.
  3. Yes, separately. #15038 and #7028 already track it.

There is a new conflict in news/changelog-1.11.md with main. No need to push anything, we'll merge main on the branch before merging.

…mpty-thumbnail-link

# Conflicts:
#	news/changelog-1.11.md
@cderv
cderv merged commit 6931b7e into quarto-dev:main Oct 20, 2026
51 checks passed
@mcanouil mcanouil added this to the v1.11 milestone Oct 20, 2026
cderv added a commit that referenced this pull request Oct 20, 2026
The default listing template wrapped the metadata block and the subtitle
line in links to the post even when neither showed any visible text.
Those links had no accessible name, which axe-core flags as a link-name
violation and which made an empty Tab stop for keyboard users. Each link
is now written only when its content renders visible text.

- Write the metadata link only when one of its fields has visible text.
  The .metadata div stays on every item so body widths match within a
  listing.
- Write the subtitle line only when the post has a subtitle.
- Treat falsy values, empty lists and blank strings as no visible text.

The empty thumbnail link had the same problem and was fixed separately
in #15099.

Fixes #14380
Fixes #15129
cderv pushed a commit that referenced this pull request Oct 27, 2026
…#9469)

With `link-external-newwindow: true`, external items in default and grid
listings still opened in the same tab. They carried `no-external`, which
makes the external-link script skip them entirely, turning off both the
new tab and the icon. A new `no-external-icon` class turns off only the
icon, so listing items now open in a new tab without gaining an icon.

- Default and grid listing items use `no-external-icon`; `no-external`
  keeps its meaning, and links inside item descriptions keep their icon.
- The external-link decoration reruns when pagination, filtering or
  sorting shows new items, and adds a string `link-external-icon` only
  once per link.
- Links opened in a new tab are announced as "(opens in a new tab)"
  (WCAG technique G201) through `aria-describedby`, from the new
  language key `link-external-newwindow-label`, so their accessible
  name is unchanged. A link title stays a tooltip but is no longer
  exposed as the accessible description.
- The empty thumbnail link of external items without an image is now
  hidden, extending #15099 to items without a file.
- `no-external-icon` is stripped from llms output.

Fixes #9468
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Default listing renders an empty, focusable thumbnail link for posts without an image

4 participants