A11Y: add H1 tags for screenreaders on discovery routes (#32422)

Over the years we've received multiple pieces of feedback around our
lack of meaningful H1 tags on our topic lists.

This adds an `<H1>`, hidden with `.sr-only`, for screenreaders that
describes the content of our discovery routes. I've tried to keep these
structured in a way that's somewhat consistent and uses natural
language.

The translations could probably be more efficiently structured, but I
think we need a good amount of flexibility here for translators to
change the sentence structure.

Ultimately this means people using screenreaders will know which page
they're on while navigating headings... and without having to parse
through our navigation.

For example (made visible to demonstrate):


![image](https://github.com/user-attachments/assets/ae397317-b167-44b9-9dfa-79ddd8f9dfda)


![image](https://github.com/user-attachments/assets/fbdb8c49-03ed-40af-ae21-c7eb841f3d3e)


![image](https://github.com/user-attachments/assets/8f36da9d-04cb-4c6b-a4c3-baffbecc64e5)


![image](https://github.com/user-attachments/assets/fc9f3f8d-35a1-4953-84cf-337a00077e26)
This commit is contained in:
Kris
2025-04-25 15:08:37 -04:00
committed by GitHub
parent ccbf88eb98
commit ca658a8bb0
6 changed files with 211 additions and 0 deletions
@@ -0,0 +1,94 @@
import Component from "@glimmer/component";
import { i18n } from "discourse-i18n";
export default class AccessibleDiscoveryHeading extends Component {
get filterKey() {
const filter = this.args.filter;
if (filter === "categories") {
return null;
}
if (filter.includes("/")) {
return filter.split("/").pop();
}
return filter;
}
get filterLabel() {
const key = this.filterKey;
// fallback to nothing, e.g, "topics in uncategorized"
// rather than "[en.discovery.headings.filter_labels.foo] topics in uncategorized"
const fallback = "";
const label = i18n(`discovery.headings.filter_labels.${key}`);
return label.includes("discovery.headings.filter_labels.")
? fallback
: label;
}
get type() {
const { category, tag, additionalTags } = this.args;
if (category && tag) {
return "category_tag";
}
if (tag && additionalTags?.length) {
return "multi_tag";
}
if (tag) {
return "single_tag";
}
if (category) {
return "category";
}
return "all";
}
get label() {
const { category, tag, additionalTags, filter } = this.args;
const type = this.type;
if (filter === "categories") {
return i18n("discovery.headings.categories");
}
const key = this.filterKey;
const isSpecial = ["posted", "bookmarks"].includes(key);
const scope = `discovery.headings`;
if (tag?.id === "none" && !additionalTags?.length) {
const noTagsType = category ? "category" : "all";
const noTagsKey = isSpecial
? `${scope}.no_tags.${noTagsType}.${key}`
: `${scope}.no_tags.${noTagsType}_default`;
return i18n(noTagsKey, {
filter: this.filterLabel,
category: category?.name,
});
}
const translationKey = isSpecial
? `${scope}.${type}.${key}`
: `${scope}.${type}.default`;
return i18n(translationKey, {
filter: this.filterLabel,
category: category?.name,
tag: tag?.id,
tags: [tag?.id, ...(additionalTags || [])].join(" + "),
});
}
<template>
<h1 id="topic-list-heading" class="sr-only">{{this.label}}</h1>
</template>
}
@@ -5,6 +5,7 @@ import { service } from "@ember/service";
import AddCategoryTagClasses from "discourse/components/add-category-tag-classes";
import CategoryLogo from "discourse/components/category-logo";
import DNavigation from "discourse/components/d-navigation";
import AccessibleDiscoveryHeading from "discourse/components/discovery/accessible-discovery-heading";
import ReorderCategories from "discourse/components/modal/reorder-categories";
import PluginOutlet from "discourse/components/plugin-outlet";
import bodyClass from "discourse/helpers/body-class";
@@ -74,6 +75,13 @@ export default class DiscoveryNavigation extends Component {
@tags={{if @tag (array @tag.id)}}
/>
<AccessibleDiscoveryHeading
@category={{@category}}
@tag={{@tag}}
@additionalTags={{@additionalTags}}
@filter={{this.filterMode}}
/>
{{#if @category}}
<PluginOutlet
@name="above-category-heading"
@@ -188,6 +188,7 @@ export default class TopicList extends Component {
(if this.bulkSelectEnabled "sticky-header bulk-select-enabled")
this.additionalClasses
}}
aria-labelledby="topic-list-heading"
...attributes
>
<caption class="sr-only">{{i18n "sr_topic_list_caption"}}</caption>
@@ -475,3 +475,8 @@ div.education {
align-self: center;
line-height: var(--line-height-medium);
}
#topic-list-heading {
line-height: var(--line-height-medium);
margin-bottom: 0.5em;
}
+47
View File
@@ -5200,6 +5200,53 @@ en:
powered_by_discourse: "Powered by Discourse"
safari_15_warning: "Your browser will soon be incompatible with this community. To keep participating here, please upgrade your browser or <a href='%{url}'>learn more</a>."
discovery:
headings:
all:
default: "All %{filter} topics"
posted: "All topics youve posted in"
bookmarks: "All topics youve bookmarked"
category:
default: "%{filter} topics in %{category}"
posted: "Topics in %{category} youve posted in"
bookmarks: "Topics in %{category} youve bookmarked"
no_tags:
all_default: "All topics without tags"
category_default: "%{filter} topics in %{category} without tags"
all:
posted: "All topics without tags you've posted in"
bookmarks: "All topics without tags you've bookmarked"
category:
posted: "Topics in %{category} without tags you've posted in"
bookmarks: "Topics in %{category} without tags you've bookmarked"
single_tag:
default: "%{filter} topics tagged %{tag}"
posted: "Topics tagged %{tag} you've posted in"
bookmarks: "Topics tagged %{tag} youve bookmarked"
multi_tag:
default: "%{filter} topics tagged %{tags}"
posted: "Topics tagged %{tags} you've posted in"
bookmarks: "Topics tagged %{tags} youve bookmarked"
category_tag:
default: "%{filter} topics in %{category} tagged %{tags}"
posted: "Topics in %{category} tagged %{tags} youve posted in"
bookmarks: "Topics in %{category} tagged %{tags} youve bookmarked"
categories: "All categories"
filter_labels:
latest: "Latest"
top: "Top"
new: "New"
unread: "Unread"
hot: "Hot"
unseen: "Unseen"
# This section is exported to the javascript for i18n in the admin section
admin_js:
# This is a text input placeholder, keep the translation short
@@ -0,0 +1,56 @@
# frozen_string_literal: true
describe "Discovery heading accessibility", type: :system do
let(:discovery) { PageObjects::Pages::Discovery.new }
fab!(:category) { Fabricate(:category, name: "General") }
fab!(:tag) { Fabricate(:tag, name: "help") }
fab!(:topic_in_category) { Fabricate(:topic, category: category) }
fab!(:topic_with_tag) { Fabricate(:topic, tags: [tag]) }
fab!(:topic_in_category_tagged) { Fabricate(:topic, category: category, tags: [tag]) }
it "shows correct heading for category view" do
visit "/c/#{category.slug}/#{category.id}"
expect(page).to have_selector("h1", text: "Latest topics in General")
end
it "shows correct heading for tagged view" do
visit "/tags/#{tag.name}"
expect(page).to have_selector("h1", text: "Latest topics tagged help")
end
it "shows correct heading for category + tag view" do
visit "/tags/c/#{category.slug}/#{category.id}/#{tag.name}"
expect(page).to have_selector("h1", text: "Latest topics in General tagged help")
end
it "shows correct heading for no tags view" do
visit "/tags/none"
expect(page).to have_selector("h1", text: "All topics without tags")
end
it "shows default heading for latest topics" do
visit "/latest"
expect(page).to have_selector("h1", text: "All Latest topics")
end
it "shows heading for all categories view" do
visit "/categories"
expect(page).to have_selector("h1", text: "All categories")
end
it "shows heading for bookmarked topics" do
sign_in(Fabricate(:user))
visit "/bookmarks"
expect(page).to have_selector("h1", text: "All topics youve bookmarked")
end
it "shows heading for posted topics" do
sign_in(Fabricate(:user))
visit "/posted"
expect(page).to have_selector("h1", text: "All topics youve posted in")
end
end