FIX: Add aria-label attribute to cooked hashtags (#22182)

This commit adds an aria-label attribute to cooked hashtags using
the post/chat message decorateCooked functionality. I have just used
the inner content of the hashtag (the tag/category/channel name) for
the label -- we can reexamine at some point if we want something
different like "Link to dev category" or something, but from what I
can tell things like Twitter don't even have aria-labels for hashtags
so the text would be read out directly.

This commit also refactors any ruby specs checking the HTML of hashtags
to use rspec-html-matchers which is far clearer than having to maintain
the HTML structure in a HEREDOC for comparison, and gives better spec
failures.

c.f. https://meta.discourse.org/t/hashtags-are-getting-a-makeover/248866/23?u=martin
This commit is contained in:
Martin Brennan 2023-06-20 15:47:17 +10:00 committed by GitHub
parent 6781e31195
commit fc199d42fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 290 additions and 85 deletions

View File

@ -1,5 +1,5 @@
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
import { replaceHashtagIconPlaceholder } from "discourse/lib/hashtag-autocomplete"; import { decorateHashtags } from "discourse/lib/hashtag-autocomplete";
export default { export default {
after: "hashtag-css-generator", after: "hashtag-css-generator",
@ -10,13 +10,10 @@ export default {
withPluginApi("0.8.7", (api) => { withPluginApi("0.8.7", (api) => {
if (siteSettings.enable_experimental_hashtag_autocomplete) { if (siteSettings.enable_experimental_hashtag_autocomplete) {
api.decorateCookedElement( api.decorateCookedElement((post) => decorateHashtags(post, site), {
(post) => replaceHashtagIconPlaceholder(post, site), onlyStream: true,
{ id: "hashtag-icons",
onlyStream: true, });
id: "hashtag-icons",
}
);
} }
}); });
}, },

View File

@ -25,8 +25,9 @@ export function cleanUpHashtagTypeClasses() {
export function getHashtagTypeClasses() { export function getHashtagTypeClasses() {
return hashtagTypeClasses; return hashtagTypeClasses;
} }
export function replaceHashtagIconPlaceholder(element, site) { export function decorateHashtags(element, site) {
element.querySelectorAll(".hashtag-cooked").forEach((hashtagEl) => { element.querySelectorAll(".hashtag-cooked").forEach((hashtagEl) => {
// Replace the empty icon placeholder span with actual icon HTML.
const iconPlaceholderEl = hashtagEl.querySelector( const iconPlaceholderEl = hashtagEl.querySelector(
".hashtag-icon-placeholder" ".hashtag-icon-placeholder"
); );
@ -41,6 +42,10 @@ export function replaceHashtagIconPlaceholder(element, site) {
.trim(); .trim();
iconPlaceholderEl.replaceWith(domFromString(hashtagIconHTML)[0]); iconPlaceholderEl.replaceWith(domFromString(hashtagIconHTML)[0]);
} }
// Add an aria-label to the hashtag element so that screen readers
// can read the hashtag text.
hashtagEl.setAttribute("aria-label", `${hashtagEl.innerText.trim()}`);
}); });
} }

View File

@ -1,5 +1,5 @@
import { decorateGithubOneboxBody } from "discourse/instance-initializers/onebox-decorators"; import { decorateGithubOneboxBody } from "discourse/instance-initializers/onebox-decorators";
import { replaceHashtagIconPlaceholder } from "discourse/lib/hashtag-autocomplete"; import { decorateHashtags } from "discourse/lib/hashtag-autocomplete";
import { withPluginApi } from "discourse/lib/plugin-api"; import { withPluginApi } from "discourse/lib/plugin-api";
import highlightSyntax from "discourse/lib/highlight-syntax"; import highlightSyntax from "discourse/lib/highlight-syntax";
import I18n from "I18n"; import I18n from "I18n";
@ -73,10 +73,9 @@ export default {
} }
); );
api.decorateChatMessage( api.decorateChatMessage((element) => decorateHashtags(element, site), {
(element) => replaceHashtagIconPlaceholder(element, site), id: "hashtagIcons",
{ id: "hashtagIcons" } });
);
}, },
_getScrollParent(node, maxParentSelector) { _getScrollParent(node, maxParentSelector) {

View File

@ -194,9 +194,20 @@ describe Chat::ChannelArchiveService do
subject.new(@channel_archive).execute subject.new(@channel_archive).execute
expect(@channel_archive.reload.complete?).to eq(true) expect(@channel_archive.reload.complete?).to eq(true)
pm_topic = Topic.private_messages.last pm_topic = Topic.private_messages.last
expect(pm_topic.first_post.cooked).to include( expect(pm_topic.first_post.cooked).to have_tag(
"<a class=\"hashtag-cooked\" href=\"#{channel.relative_url}\" data-type=\"channel\" data-slug=\"#{channel.slug}\" data-id=\"#{channel.id}\" data-ref=\"#{channel.slug}::channel\"><span class=\"hashtag-icon-placeholder\"></span><span>#{channel.title(user)}</span></a>", "a",
) with: {
class: "hashtag-cooked",
href: channel.relative_url,
"data-type": "channel",
"data-slug": channel.slug,
"data-id": channel.id,
"data-ref": "#{channel.slug}::channel",
},
) do
with_tag("span", with: { class: "hashtag-icon-placeholder" })
with_tag("span", text: channel.title(user))
end
end end
end end

View File

@ -241,7 +241,7 @@ describe Chat::Message do
) )
end end
it "supports hashtag-autocomplete plugin" do it "supports hashtag autocomplete" do
SiteSetting.chat_enabled = true SiteSetting.chat_enabled = true
SiteSetting.enable_experimental_hashtag_autocomplete = true SiteSetting.enable_experimental_hashtag_autocomplete = true
@ -250,9 +250,18 @@ describe Chat::Message do
cooked = described_class.cook("##{category.slug}", user_id: user.id) cooked = described_class.cook("##{category.slug}", user_id: user.id)
expect(cooked).to eq( expect(cooked).to have_tag(
"<p><a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"#{category.slug}\" data-id=\"#{category.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>#{category.name}</span></a></p>", "a",
) with: {
class: "hashtag-cooked",
href: category.url,
"data-type": "category",
"data-slug": category.slug,
"data-id": category.id,
},
) do
with_tag("span", with: { class: "hashtag-icon-placeholder" })
end
end end
it "supports censored plugin" do it "supports censored plugin" do

View File

@ -68,15 +68,63 @@ describe "Using #hashtag autocompletion to search for and lookup channels", type
cooked_hashtags = page.all(".hashtag-cooked", count: 3) cooked_hashtags = page.all(".hashtag-cooked", count: 3)
expect(cooked_hashtags[0]["outerHTML"]).to eq(<<~HTML.chomp) expect(cooked_hashtags[0]["outerHTML"]).to have_tag(
<a class=\"hashtag-cooked\" href=\"#{channel2.relative_url}\" data-type=\"channel\" data-slug=\"random\" data-id=\"#{channel2.id}\"><svg class=\"fa d-icon d-icon-comment svg-icon hashtag-color--channel-#{channel2.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#comment\"></use></svg><span>Random</span></a> "a",
HTML with: {
expect(cooked_hashtags[1]["outerHTML"]).to eq(<<~HTML.chomp) class: "hashtag-cooked",
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"raspberry-beret\" data-id="#{category.id}"><span class=\"hashtag-category-badge hashtag-color--category-#{category.id}\"></span><span>Raspberry</span></a> href: channel2.relative_url,
HTML "data-type": "channel",
expect(cooked_hashtags[2]["outerHTML"]).to eq(<<~HTML.chomp) "data-slug": "random",
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"razed\" data-id="#{tag.id}"><svg class=\"fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#tag\"></use></svg><span>razed</span></a> "data-id": channel2.id,
HTML "aria-label": "Random",
},
) do
with_tag(
"svg",
with: {
class:
"fa d-icon d-icon-comment svg-icon hashtag-color--channel-#{channel2.id} svg-string",
},
) { with_tag("use", with: { href: "#comment" }) }
end
expect(cooked_hashtags[1]["outerHTML"]).to have_tag(
"a",
with: {
class: "hashtag-cooked",
href: category.url,
"data-type": "category",
"data-slug": "raspberry-beret",
"data-id": category.id,
"aria-label": "Raspberry",
},
) do
with_tag(
"span",
with: {
class: "hashtag-category-badge hashtag-color--category-#{category.id}",
},
)
end
expect(cooked_hashtags[2]["outerHTML"]).to have_tag(
"a",
with: {
class: "hashtag-cooked",
href: tag.url,
"data-type": "tag",
"data-slug": "razed",
"data-id": tag.id,
"aria-label": "razed",
},
) do
with_tag(
"svg",
with: {
class: "fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string",
},
) { with_tag("use", with: { href: "#tag" }) }
end
end end
context "when a user cannot access the category for a cooked channel hashtag" do context "when a user cannot access the category for a cooked channel hashtag" do

View File

@ -109,12 +109,10 @@ RSpec.describe Email::Styles do
end end
it "replaces hashtag-cooked text with raw #hashtag" do it "replaces hashtag-cooked text with raw #hashtag" do
hashtag_html = category = Fabricate(:category, name: "dev", slug: "dev")
"<a class=\"hashtag-cooked\" href=\"#{Discourse.base_url}/c/123/dev\" data-type=\"category\" data-slug=\"dev\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>Dev Zone</span></a>" post = Fabricate(:post, raw: "this is #dev")
frag = html_fragment(hashtag_html) post.rebake!
expect(frag.at("a").text.chomp).to eq("#dev") hashtag_html = post.cooked
hashtag_html =
"<a class=\"hashtag-cooked\" href=\"#{Discourse.base_url}/c/123/dev\" data-type=\"category\" data-slug=\"dev\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\">Dev Zone</a>"
frag = html_fragment(hashtag_html) frag = html_fragment(hashtag_html)
expect(frag.at("a").text.chomp).to eq("#dev") expect(frag.at("a").text.chomp).to eq("#dev")
end end

View File

@ -190,9 +190,31 @@ RSpec.describe Oneboxer do
.inner_html .inner_html
.chomp .chomp
.strip .strip
expect(preview).to eq(<<~HTML.chomp.strip) expect(preview).to include("This post has some hashtags")
This post has some hashtags, <a class="hashtag-cooked" href="#{category.url}" data-type="category" data-slug="random" data-id="#{category.id}"><span class="hashtag-icon-placeholder"></span>#{category.name}</a> and <a class="hashtag-cooked" href="#{tag.url}" data-type="tag" data-slug="bug" data-id="#{tag.id}"><span class="hashtag-icon-placeholder"></span>#{tag.name}</a> expect(preview).to have_tag(
HTML "a",
with: {
class: "hashtag-cooked",
href: category.url,
"data-type": "category",
"data-slug": category.slug,
"data-id": category.id,
},
) do
with_tag("span", with: { class: "hashtag-icon-placeholder" })
end
expect(preview).to have_tag(
"a",
with: {
class: "hashtag-cooked",
href: tag.url,
"data-type": "tag",
"data-slug": tag.name,
"data-id": tag.id,
},
) do
with_tag("span", with: { class: "hashtag-icon-placeholder" })
end
end end
end end

View File

@ -1776,24 +1776,60 @@ RSpec.describe PrettyText do
cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id) cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id)
expect(cooked).to include("<span class=\"hashtag-raw\">#unknown::tag</span>") expect(cooked).to have_tag("span", text: "#unknown::tag", with: { class: "hashtag-raw" })
expect(cooked).to include( expect(cooked).to have_tag(
"<a class=\"hashtag-cooked\" href=\"#{category2.url}\" data-type=\"category\" data-slug=\"known\" data-id=\"#{category2.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>known</span></a>", "a",
) with: {
expect(cooked).to include( class: "hashtag-cooked",
"<a class=\"hashtag-cooked\" href=\"/tag/known\" data-type=\"tag\" data-slug=\"known\" data-id=\"#{tag.id}\" data-ref=\"known::tag\"><span class=\"hashtag-icon-placeholder\"></span><span>known</span></a>", href: category2.url,
) "data-type": "category",
expect(cooked).to include( "data-slug": category2.slug,
"<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"testing\" data-id=\"#{category.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>testing</span></a>", "data-id": category2.id,
) },
expect(cooked).to include("<span class=\"hashtag-raw\">#secret</span>") ) do
with_tag("span", with: { class: "hashtag-icon-placeholder" })
end
expect(cooked).to have_tag(
"a",
with: {
class: "hashtag-cooked",
href: category.url,
"data-type": "category",
"data-slug": category.slug,
"data-id": category.id,
},
) do
with_tag("span", with: { class: "hashtag-icon-placeholder" })
end
expect(cooked).to have_tag(
"a",
with: {
class: "hashtag-cooked",
href: tag.url,
"data-type": "tag",
"data-slug": tag.name,
"data-id": tag.id,
},
) do
with_tag("span", with: { class: "hashtag-icon-placeholder" })
end
expect(cooked).to have_tag("span", text: "#secret", with: { class: "hashtag-raw" })
# If the user hash access to the private category it should be cooked with the details + icon # If the user hash access to the private category it should be cooked with the details + icon
group.add(user) group.add(user)
cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id) cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id)
expect(cooked).to include( expect(cooked).to have_tag(
"<a class=\"hashtag-cooked\" href=\"#{private_category.url}\" data-type=\"category\" data-slug=\"secret\" data-id=\"#{private_category.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>secret</span></a>", "a",
) with: {
class: "hashtag-cooked",
href: private_category.url,
"data-type": "category",
"data-slug": private_category.slug,
"data-id": private_category.id,
},
) do
with_tag("span", with: { class: "hashtag-icon-placeholder" })
end
cooked = PrettyText.cook("[`a` #known::tag here](http://example.com)", user_id: user.id) cooked = PrettyText.cook("[`a` #known::tag here](http://example.com)", user_id: user.id)
@ -1809,10 +1845,18 @@ RSpec.describe PrettyText do
expect(cooked).to eq(html.strip) expect(cooked).to eq(html.strip)
cooked = PrettyText.cook("<A href='/a'>test</A> #known::tag", user_id: user.id) cooked = PrettyText.cook("<A href='/a'>test</A> #known::tag", user_id: user.id)
html = <<~HTML expect(cooked).to have_tag(
<p><a href="/a">test</a> <a class="hashtag-cooked" href="/tag/known" data-type="tag" data-slug="known" data-id=\"#{tag.id}\" data-ref="known::tag"><span class=\"hashtag-icon-placeholder\"></span><span>known</span></a></p> "a",
HTML with: {
expect(cooked).to eq(html.strip) class: "hashtag-cooked",
href: tag.url,
"data-type": "tag",
"data-slug": tag.name,
"data-id": tag.id,
},
) do
with_tag("span", with: { class: "hashtag-icon-placeholder" })
end
# ensure it does not fight with the autolinker # ensure it does not fight with the autolinker
expect(PrettyText.cook(" http://somewhere.com/#known")).not_to include("hashtag") expect(PrettyText.cook(" http://somewhere.com/#known")).not_to include("hashtag")
@ -2051,22 +2095,35 @@ HTML
replacement: "discourse", replacement: "discourse",
) )
expect(PrettyText.cook("@test #test test")).to match_html(<<~HTML) cooked = PrettyText.cook("@test #test test")
<p> expect(cooked).to have_tag("a", text: "@test", with: { class: "mention", href: "/u/test" })
<a class="mention" href="/u/test">@test</a> expect(cooked).to have_tag(
<a class="hashtag" href="/c/test/#{category.id}">#<span>test</span></a> "a",
tdiscourset text: "#test",
</p> with: {
HTML class: "hashtag",
href: "/c/test/#{category.id}",
},
)
expect(cooked).to include("tdiscourset")
SiteSetting.enable_experimental_hashtag_autocomplete = true SiteSetting.enable_experimental_hashtag_autocomplete = true
expect(PrettyText.cook("@test #test test")).to match_html(<<~HTML) cooked = PrettyText.cook("@test #test test")
<p> expect(cooked).to have_tag("a", text: "@test", with: { class: "mention", href: "/u/test" })
<a class="mention" href="/u/test">@test</a> expect(cooked).to have_tag(
<a class="hashtag-cooked" href="#{category.url}" data-type="category" data-slug="test" data-id="#{category.id}"><span class="hashtag-icon-placeholder"></span><span>test</span></a> "a",
tdiscourset text: "test",
</p> with: {
HTML class: "hashtag-cooked",
href: "/c/test/#{category.id}",
"data-type": "category",
"data-slug": category.slug,
"data-id": category.id,
},
) do
with_tag("span", with: { class: "hashtag-icon-placeholder" })
end
expect(cooked).to include("tdiscourset")
end end
it "supports overlapping words" do it "supports overlapping words" do

View File

@ -53,25 +53,53 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
) )
end end
it "cooks the selected hashtag clientside with the correct url and icon" do it "cooks the selected hashtag clientside in the composer preview with the correct url and icon" do
visit_topic_and_initiate_autocomplete visit_topic_and_initiate_autocomplete
hashtag_results = page.all(".hashtag-autocomplete__link", count: 2) hashtag_results = page.all(".hashtag-autocomplete__link", count: 2)
hashtag_results[0].click hashtag_results[0].click
expect(page).to have_css(".hashtag-cooked") expect(page).to have_css(".hashtag-cooked")
cooked_hashtag = page.find(".hashtag-cooked") cooked_hashtag = page.find(".hashtag-cooked")
expected = <<~HTML.chomp
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-id=\"#{category.id}\" data-slug=\"cool-cat\" tabindex=\"-1\"><span class="hashtag-category-badge hashtag-color--category-#{category.id}"></span><span>Cool Category</span></a> expect(cooked_hashtag["outerHTML"]).to have_tag(
HTML "a",
expect(cooked_hashtag["outerHTML"].squish).to eq(expected) with: {
class: "hashtag-cooked",
href: category.url,
"data-type": "category",
"data-slug": category.slug,
"data-id": category.id,
},
) do
with_tag(
"span",
with: {
class: "hashtag-category-badge hashtag-color--category-#{category.id}",
},
)
end
visit_topic_and_initiate_autocomplete visit_topic_and_initiate_autocomplete
hashtag_results = page.all(".hashtag-autocomplete__link", count: 2) hashtag_results = page.all(".hashtag-autocomplete__link", count: 2)
hashtag_results[1].click hashtag_results[1].click
expect(page).to have_css(".hashtag-cooked") expect(page).to have_css(".hashtag-cooked")
cooked_hashtag = page.find(".hashtag-cooked") cooked_hashtag = page.find(".hashtag-cooked")
expect(cooked_hashtag["outerHTML"].squish).to eq(<<~HTML.chomp) expect(cooked_hashtag["outerHTML"]).to have_tag(
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-id=\"#{tag.id}\" data-slug=\"cooltag\" tabindex=\"-1\"><svg class=\"fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#tag\"></use></svg><span>cooltag</span></a> "a",
HTML with: {
class: "hashtag-cooked",
href: tag.url,
"data-type": "tag",
"data-slug": tag.name,
"data-id": tag.id,
},
) do
with_tag(
"svg",
with: {
class: "fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string",
},
) { with_tag("use", with: { href: "#tag" }) }
end
end end
it "cooks the hashtags for tag and category correctly serverside when the post is saved to the database" do it "cooks the hashtags for tag and category correctly serverside when the post is saved to the database" do
@ -85,12 +113,43 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
cooked_hashtags = page.all(".hashtag-cooked", count: 2) cooked_hashtags = page.all(".hashtag-cooked", count: 2)
expect(cooked_hashtags[0]["outerHTML"]).to eq(<<~HTML.chomp) expect(cooked_hashtags[0]["outerHTML"]).to have_tag(
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"cool-cat\" data-id=\"#{category.id}\"><span class=\"hashtag-category-badge hashtag-color--category-#{category.id}\"></span><span>Cool Category</span></a> "a",
HTML with: {
expect(cooked_hashtags[1]["outerHTML"]).to eq(<<~HTML.chomp) class: "hashtag-cooked",
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"cooltag\" data-id=\"#{tag.id}\"><svg class=\"fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#tag\"></use></svg><span>cooltag</span></a> href: category.url,
HTML "data-type": "category",
"data-slug": category.slug,
"data-id": category.id,
"aria-label": category.name,
},
) do
with_tag(
"span",
with: {
class: "hashtag-category-badge hashtag-color--category-#{category.id}",
},
)
end
expect(cooked_hashtags[1]["outerHTML"]).to have_tag(
"a",
with: {
class: "hashtag-cooked",
href: tag.url,
"data-type": "tag",
"data-slug": tag.name,
"data-id": tag.id,
"aria-label": tag.name,
},
) do
with_tag(
"svg",
with: {
class: "fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string",
},
) { with_tag("use", with: { href: "#tag" }) }
end
end end
context "when a user cannot access the category for a hashtag cooked in another post" do context "when a user cannot access the category for a hashtag cooked in another post" do