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
2 changes: 2 additions & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ All changes included in 1.11:
- ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Translate the new `navigation-*-label` keys in all 33 built-in language files. The values are drawn from existing human-translated interface strings (LibreOffice, GNOME, Wikidata) and each one carries a comment naming its source; values that were adapted rather than used verbatim are marked `needs review`.
- ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Label the table of contents `<nav>` with its localized title (`aria-labelledby`), in `html` and `revealjs` output, so assistive technology can tell it apart from other navigation landmarks.
- ([#14378](https://github.com/quarto-dev/quarto-cli/issues/14378)): Make scrollable code blocks and cell output keyboard-focusable in HTML output, so keyboard users can Tab to them and scroll with the arrow keys (axe rule `scrollable-region-focusable`, WCAG 2.1.1). While a region overflows it gets `tabindex="0"`, `role="group"`, and a localized `aria-label` (new `scrollable-code-label` and `scrollable-output-label` language keys, translated in all 34 built-in language files); when it fits again the attributes are removed. Chrome and Firefox already focus such regions natively; this adds Safari coverage and an accessible name.
- ([#14380](https://github.com/quarto-dev/quarto-cli/issues/14380)): Fix default listings rendering an empty, focusable subtitle link for posts without a subtitle, flagged by axe-core (`link-name`).
- ([#14795](https://github.com/quarto-dev/quarto-cli/issues/14795)): Fix keyboard focus in `revealjs` presentations reaching links and controls on nearby slides that are not shown. Tab now stays on the current slide, except in overview, scroll and print views where all slides are shown.
- ([#14952](https://github.com/quarto-dev/quarto-cli/issues/14952)): Fix `revealjs` presentations disabling pinch-zoom on touch devices. The viewport `<meta>` tag no longer sets `maximum-scale=1.0` and `user-scalable=no`, which axe-core flagged (`meta-viewport`).
- ([#14968](https://github.com/quarto-dev/quarto-cli/issues/14968)): The header of notebook preview pages (back link, notebook title, download link) is now a navigation landmark named after the notebook, so it is reachable through landmark navigation and no longer flagged by axe-core (`region`).
Expand All @@ -39,6 +40,7 @@ All changes included in 1.11:
- ([#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.
- ([#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`).

## Formats

Expand Down
29 changes: 25 additions & 4 deletions src/resources/projects/website/listing/item-default.ejs.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ return !["title", "image", "image-alt", "date", "author", "subtitle", "descripti
// Capture the item number for utility functions that need it
const itemNumber = listing.utilities.itemNumber();

// Writes a metadata value
const outputMetadata = (item, field) => {

// Reads a (possibly dotted) metadata field from an item
const readField = (item, field) => {
let value = item[field];
if (field.includes(".") && !field.endsWith(".") && !field.startsWith(".")) {
Expand All @@ -29,6 +27,21 @@ value = value[deref];
return value;
}

// Whether a value renders as visible text (falsy values render as &nbsp;,
// empty lists and blank strings as whitespace)
const hasText = (value) => !!value && String(value).trim() !== "";

// Whether the metadata block shows any text for the item
const hasMetadata = (item) => {
return (fields.includes('date') && hasText(item.date)) ||
(fields.includes('author') && hasText(item.author)) ||
(fields.includes('reading-time') && hasText(item['reading-time'])) ||
otherFields.some(field => hasText(readField(item, field)));
}

// Writes a metadata value
const outputMetadata = (item, field) => {

let value = readField(item, field);
if (value !== undefined) {
print(`<div class="metadata-value listing-${field}">${listing.utilities.outputLink(item, field, value)}</div>`);
Expand Down Expand Up @@ -56,7 +69,7 @@ print(`<div class="metadata-value listing-${field}">${listing.utilities.outputLi

<% if (fields.includes('title')) { %>
<h3 class="no-anchor listing-title"><a href="<%- item.path %>" class="no-external"><%= item.title %></a></h3>
<% if (fields.includes('subtitle')) { %>
<% if (fields.includes('subtitle') && hasText(item.subtitle)) { %>
<div class="listing-subtitle"><a href="<%- item.path %>" class="no-external"><%= item.subtitle %></a></div>
<% } %>
<% } %>
Expand Down Expand Up @@ -91,10 +104,14 @@ print(`<div class="metadata-value listing-${field}">${listing.utilities.outputLi

::: {.metadata}

<% if (hasMetadata(item)) { %>

```{=html}
<a href="<%- item.path %>" class="no-external">
```

<% } %>

<% if (fields.includes('date') && item.date) { %>
<div class="listing-date"><%= item.date %></div>
<% } %>
Expand All @@ -115,10 +132,14 @@ print(`<div class="metadata-value listing-${field}">${listing.utilities.outputLi
<% outputMetadata(item, field) %>
<% } %>

<% if (hasMetadata(item)) { %>

```{=html}
</a>
```

<% } %>

:::

:::
4 changes: 4 additions & 0 deletions tests/docs/listings-metadata-link/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/.quarto/
_site/

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

website:
title: "Listing metadata link"
10 changes: 10 additions & 0 deletions tests/docs/listings-metadata-link/custom-field.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: "Posts with a custom field"
listing:
contents: posts
type: default
sort: "title"
fields: [title, version]
---

A default listing with a custom metadata field.
10 changes: 10 additions & 0 deletions tests/docs/listings-metadata-link/fields.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: "Posts without metadata fields"
listing:
contents: posts
type: default
sort: "title"
fields: [image, title]
---

A default listing whose fields exclude every metadata field.
10 changes: 10 additions & 0 deletions tests/docs/listings-metadata-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 the default fields. The post without a date or
an author has nothing to show in its metadata block.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: "Post with a blank version"
version: " "
---

Text.
9 changes: 9 additions & 0 deletions tests/docs/listings-metadata-link/posts/dated/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: "Post with a date and an author"
date: 2026-01-01
author: "Jane Doe"
subtitle: "A subtitle"
version: "1.0"
---

Text.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: "Post with an empty version list"
version: []
---

Text.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: "Post with an empty version"
version: ""
---

Text.
5 changes: 5 additions & 0 deletions tests/docs/listings-metadata-link/posts/undated/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: "Post without a date or an author"
---

Text.
10 changes: 10 additions & 0 deletions tests/docs/listings-metadata-link/reading-time.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
title: "Posts with reading time"
listing:
contents: posts
type: default
sort: "title"
fields: [title, reading-time]
---

A default listing whose only metadata field is the reading time.
85 changes: 85 additions & 0 deletions tests/smoke/site/render-listing-metadata-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* render-listing-metadata-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 {
ensureHtmlElementCount,
ensureHtmlElements,
noErrorsOrWarnings,
} from "../../verify.ts";

// The default listing template wraps the metadata block of every post in a
// link to the post. When nothing in that block shows text, the link is empty:
// a tab stop with no accessible name. The link is only written when the
// block has visible content. Cases covered: no date or author, fields that
// exclude every metadata field, a listed custom field that is missing, empty,
// blank or an empty list on the post. Every post keeps its metadata column to
// preserve the row layout. The subtitle link is likewise only written for a
// post with a subtitle.
const projectDir = docs("listings-metadata-link");
const outputDir = join(projectDir, "_site");
const metadataColumn = "div.quarto-post > div.metadata";

testQuartoCmd("render", [projectDir], [
noErrorsOrWarnings,
...["index", "fields", "reading-time", "custom-field"].map((page) =>
ensureHtmlElementCount(join(outputDir, `${page}.html`), {
selectors: metadataColumn,
counts: 5,
})
),
// default fields
ensureHtmlElements(join(outputDir, "index.html"), [
'div.metadata a[href$="/dated/index.html"] div.listing-date',
'div.metadata a[href$="/dated/index.html"] div.listing-author',
], [
'div.metadata a[href$="/undated/index.html"]',
'div.metadata a[href$="/empty-version/index.html"]',
]),
// only the post with a subtitle gets a subtitle link (#14380)
ensureHtmlElementCount(join(outputDir, "index.html"), {
selectors: [
'div.listing-subtitle a[href$="/dated/index.html"]',
"div.listing-subtitle",
],
counts: [1, 1],
}),
// fields exclude every metadata field
ensureHtmlElements(join(outputDir, "fields.html"), [], [
"div.metadata a",
]),
// every post has a reading time, so every post keeps its link
ensureHtmlElements(join(outputDir, "reading-time.html"), [
'div.metadata a[href$="/dated/index.html"] div.listing-reading-time',
'div.metadata a[href$="/undated/index.html"] div.listing-reading-time',
'div.metadata a[href$="/empty-version/index.html"] div.listing-reading-time',
'div.metadata a[href$="/blank-version/index.html"] div.listing-reading-time',
'div.metadata a[href$="/empty-list-version/index.html"] div.listing-reading-time',
], []),
// the link is only written where version has a value
ensureHtmlElements(join(outputDir, "custom-field.html"), [
'div.metadata a[href$="/dated/index.html"] div.listing-version',
], [
'div.metadata a[href$="/undated/index.html"]',
'div.metadata a[href$="/empty-version/index.html"]',
'div.metadata a[href$="/blank-version/index.html"]',
'div.metadata a[href$="/empty-list-version/index.html"]',
]),
// every post but undated keeps its listing-version div
ensureHtmlElementCount(join(outputDir, "custom-field.html"), {
selectors: "div.metadata div.listing-version",
counts: 4,
}),
], {
name: "Listing metadata link is only written when it has content",
teardown: () => {
removeIfExists(outputDir);
removeIfExists(join(projectDir, ".quarto"));
return Promise.resolve();
},
});
Loading