Skip to content

Commit aad85b2

Browse files
authored
Fix empty item links in listings when a description contains a link (#15213)
Default and grid listings wrap the item description in a link to the item, so a link inside the description produced nested anchors. The HTML parser split them into empty, unnamed links, which axe reports as link-name. The delink filter flattens such links to text, but it only matches Pandoc Divs, and the item templates stopped emitting one in 1.7 when they moved to raw HTML (see #11758), so the filter no longer ran. The fenced div is restored so the filter applies again, and only the item link stays raw HTML. Description links are text again, as in 1.6, so the whole description links to the item. Making them clickable is discussed in #10814 and is not part of this change. - Restore the `delink` container in the default and grid item templates. - Remove the empty-link exclusion from #9469's listing axe test. - Remove the description link icon test from #9469 and cover both listings with a smoke-all project. - Move the custom icon pagination test from the default listing to a table listing, whose title link keeps the external icon. Fixes #15211
1 parent 210691f commit aad85b2

9 files changed

Lines changed: 93 additions & 39 deletions

File tree

‎news/changelog-1.11.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ All changes included in 1.11:
4949
- ([#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.
5050
- ([#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`).
5151
- ([#15159](https://github.com/quarto-dev/quarto-cli/issues/15159), [#15160](https://github.com/quarto-dev/quarto-cli/issues/15160)): Fix the `revealjs` speaker view's Layout selector having no accessible name or visible keyboard focus, and its current slide, upcoming slide, time, and notes areas not being named regions. Changing layouts no longer forwards control keys to the slides, and the slide navigation buttons no longer introduce an unrelated complementary landmark. (author: @jooyoungseo)
52+
- ([#15211](https://github.com/quarto-dev/quarto-cli/issues/15211)): Fix default and grid listings rendering empty, focusable links when an item description contains a link, flagged by axe-core (`link-name`), a regression introduced in v1.7.7. Links in descriptions are shown as text again, so the whole description links to the item.
5253

5354
## Formats
5455

‎src/resources/projects/website/listing/item-default.ejs.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,20 @@ print(`<div class="metadata-value listing-${field}">${listing.utilities.outputLi
8888

8989
<% if (fields.includes('description')) { %>
9090

91+
::: {.delink .listing-description}
92+
9193
```{=html}
92-
<div class="delink listing-description"><a href="<%- item.path %>" class="no-external-icon">
94+
<a href="<%- item.path %>" class="no-external-icon">
9395
```
9496

9597
<%= item.description %>
9698

9799
```{=html}
98-
</a></div>
100+
</a>
99101
```
100102

103+
:::
104+
101105
<% } %>
102106

103107
:::

‎src/resources/projects/website/listing/item-grid.ejs.md‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,11 @@ return !["title", "image", "image-alt", "date", "author", "subtitle", "descripti
9797

9898
<% if (showField('description')) { %>
9999

100-
```{=html}
101-
<div class="card-text listing-description delink">
102-
```
100+
::: {.card-text .listing-description .delink}
103101

104102
<%= item.description %>
105103

106-
```{=html}
107-
</div>
108-
```
104+
:::
109105

110106
<% } %>
111107

‎tests/docs/playwright/listings/external-links/custom-icon.qmd‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ listing:
1010
title: "Local Post"
1111
- path: https://example.com/external-article
1212
title: "External Article"
13-
description: "An external article, see the [docs](https://example.org/docs)."
14-
type: default
13+
type: table
14+
fields: [title]
1515
sort: "title desc"
1616
page-size: 1
1717
---
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/.quarto/
2+
_site/
3+
4+
**/*.quarto_ipynb
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
project:
2+
type: website
3+
4+
website:
5+
title: "Listing descriptions with links"
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
title: "Default listing, description with a link"
3+
listing:
4+
id: description-links
5+
type: default
6+
contents:
7+
- path: https://example.com/article
8+
title: "Article"
9+
description: "Read the article, see the [docs](https://example.org/docs)."
10+
_quarto:
11+
render-project: true
12+
tests:
13+
html:
14+
ensureHtmlElements:
15+
-
16+
# the description stays inside the link to the item
17+
- ".quarto-listing-container-default .listing-description > a[href='https://example.com/article'] span"
18+
-
19+
# the link must be removed, not nested in the item's wrapping <a>
20+
- ".quarto-listing-container-default .listing-description a[href='https://example.org/docs']"
21+
ensureFileRegexMatches:
22+
-
23+
# the link text is kept
24+
- '<span>docs</span>'
25+
---
26+
27+
::: {#description-links}
28+
:::
29+
30+
The default listing wraps the item description in a link to the item. A link
31+
inside the description would nest one `<a>` in another, which the HTML parser
32+
splits into empty, unnamed links. The description link must be turned into
33+
plain text instead.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
title: "Grid listing, description with a link"
3+
listing:
4+
id: description-links
5+
type: grid
6+
contents:
7+
- path: https://example.com/article
8+
title: "Article"
9+
description: "Read the article, see the [docs](https://example.org/docs)."
10+
_quarto:
11+
render-project: true
12+
tests:
13+
html:
14+
ensureHtmlElements:
15+
-
16+
# the description stays inside the card's link to the item
17+
- ".quarto-listing-container-grid a.quarto-grid-link[href='https://example.com/article'] .listing-description span"
18+
-
19+
# the link must be removed, not nested in the item's wrapping <a>
20+
- ".quarto-listing-container-grid .listing-description a[href='https://example.org/docs']"
21+
ensureFileRegexMatches:
22+
-
23+
# the link text is kept
24+
- '<span>docs</span>'
25+
---
26+
27+
::: {#description-links}
28+
:::
29+
30+
The grid listing wraps the whole item card in a link to the item. A link
31+
inside the description would nest one `<a>` in another, which the HTML parser
32+
splits into empty, unnamed links. The description link must be turned into
33+
plain text instead.

‎tests/integration/playwright/tests/html-listing-external-links.spec.ts‎

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,10 @@ listingTypes.forEach(
9494
const violations = await browserPage.evaluate(async (selector) => {
9595
const axe = (window as any).axe;
9696
const container = document.querySelector(selector)!;
97-
// A description containing a link splits the item's wrapping <a>,
98-
// leaving empty links that fail link-name; a separate bug.
99-
const emptyWrappers = Array.from(
100-
container.querySelectorAll("a"),
101-
).filter((a) => a.children.length === 0 && a.textContent!.trim() === "");
10297
// heading-order judges the listing heading levels against the
10398
// page, which this test does not cover.
10499
const result = await axe.run(
105-
{ include: [container], exclude: emptyWrappers },
100+
{ include: [container] },
106101
{ rules: { "heading-order": { enabled: false } } },
107102
);
108103
return result.violations.map(
@@ -127,24 +122,6 @@ listingTypes.forEach(
127122
await browserPage.evaluate(afterContent(itemAnchorSelector)),
128123
).toBe("none");
129124
});
130-
131-
test("description link keeps its external icon", async ({ page: browserPage }) => {
132-
await browserPage.goto(`./listings/external-links/_site/${page}`);
133-
await browserPage.waitForSelector(containerSelector);
134-
135-
const descriptionLink = browserPage
136-
.locator(containerSelector)
137-
.locator('a[href="https://example.org/docs"]');
138-
139-
await expect(descriptionLink).toHaveClass(/external/);
140-
const content = await browserPage.evaluate(
141-
afterContent(
142-
`${containerSelector} a[href="https://example.org/docs"]`,
143-
),
144-
);
145-
expect(content).not.toBe("none");
146-
expect(content).not.toBe("missing");
147-
});
148125
}
149126
});
150127
},
@@ -183,18 +160,19 @@ test.describe("External links shown after listing updates", () => {
183160
browserPage.locator("#custom-icon-listing-pagination .page.page-link", {
184161
hasText: n,
185162
});
186-
const descriptionLink = browserPage.locator(
187-
'.quarto-listing-default a[href="https://example.org/docs"]',
163+
// Table listing titles are the only item links that keep the icon.
164+
const titleLink = browserPage.locator(
165+
'.quarto-listing-table a.listing-title[href="https://example.com/external-article"]',
188166
);
189167

190168
for (const n of ["2", "1", "2", "1", "2"]) {
191169
await pageLink(n).click();
192170
}
193-
await expect(descriptionLink).toBeVisible();
171+
await expect(titleLink).toBeVisible();
194172
// The icon is inserted as the next sibling of the link it decorates.
195-
await expect(descriptionLink.locator("xpath=following-sibling::i[contains(@class, 'quarto-ext-icon')]"))
173+
await expect(titleLink.locator("xpath=following-sibling::i[contains(@class, 'quarto-ext-icon')]"))
196174
.toHaveCount(1);
197-
await expect(descriptionLink).toHaveAttribute("aria-describedby", newWindowNoteId);
175+
await expect(titleLink).toHaveAttribute("aria-describedby", newWindowNoteId);
198176
});
199177
});
200178

0 commit comments

Comments
 (0)