Skip to content

Commit 10e108a

Browse files
authored
fix(tags): add screen reader improvements (#2045)
* make stacks docs accessible * make tags svelte component accessible * parameterize status sr text for i18n * add tests * don't use aria-label * adjust tests * lint * Create rare-badgers-vanish.md * pr feedback * simplify examples again
1 parent 3a5064d commit 10e108a

File tree

4 files changed

+195
-64
lines changed

4 files changed

+195
-64
lines changed

.changeset/rare-badgers-vanish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stackoverflow/stacks-svelte": patch
3+
---
4+
5+
accessibility(tags): add screen reader improvements

packages/stacks-docs/product/components/tags.html

Lines changed: 39 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
</tbody>
2929
</table>
3030
</div>
31+
{% header "h2", "Accessibility" %}
32+
<p class="stacks-copy">
33+
Tags should be focusable and navigable with the keyboard.
34+
The various tag states (Required, Moderator, Watched, Ignored) are visually distinct but do not include any text indicators for screen readers.
35+
For that reason it is recommended to provide additional context using hidden text elements with the <code class="stacks-code">v-visible-sr</code> class.
36+
</p>
3137
</section>
3238

3339
<section class="stacks-section">
@@ -36,95 +42,74 @@
3642
<div class="stacks-preview">
3743
{% highlight html %}
3844
<a class="s-tag" href="#">jquery</a>
39-
<span class="s-tag">javascript <button class="s-tag--dismiss">@Svg.ClearSm</button></span>
40-
<a class="s-tag" href="#"><img class="s-tag--sponsor" src="https://i.stack.imgur.com/tKsDb.png" width="16" height="18" alt="Google Android"> android</a>
45+
<span class="s-tag">javascript <button class="s-tag--dismiss"><span class="v-visible-sr">Dismiss javascript tag</span>@Svg.ClearSm</button></span>
46+
<a class="s-tag" href="#"><img class="s-tag--sponsor" src="https://i.stack.imgur.com/tKsDb.png" width="16" height="18" alt="Google Android"> android <div class="v-visible-sr">Sponsored tag</div></a>
4147
{% endhighlight %}
4248
<div class="stacks-preview--example">
4349
<div class="d-flex g4 fw-wrap">
4450
<a class="s-tag" href="#">jquery</a>
45-
<span class="s-tag" href="#">javascript <button class="s-tag--dismiss">{% icon "ClearSm" %}</button></span>
46-
<a class="s-tag" href="#"><img class="s-tag--sponsor" src="https://i.stack.imgur.com/tKsDb.png" width="16" height="18" alt="Google Android"> android</a>
51+
<span class="s-tag">javascript <button class="s-tag--dismiss"><span class="v-visible-sr">Dismiss javascript tag</span>{% icon "ClearSm" %}</button></span>
52+
<a class="s-tag" href="#"><img class="s-tag--sponsor" src="https://i.stack.imgur.com/tKsDb.png" width="16" height="18" alt="Google Android"> android <div class="v-visible-sr">Sponsored tag</div></a>
4753
</div>
4854
</div>
4955
</div>
5056

51-
{% header "h3", "Moderator" %}
52-
<div class="stacks-preview">
57+
{% header "h3", "Moderator" %}
58+
<div class="stacks-preview">
5359
{% highlight html %}
54-
<a class="s-tag s-tag__moderator" href="#">status-completed</a>
55-
<span class="s-tag s-tag__moderator">status-bydesign <button class="s-tag--dismiss">@Svg.ClearSm</button></span>
56-
<a class="s-tag s-tag__moderator" href="#">status-planned</a>
60+
<a class="s-tag s-tag__moderator" href="#">status-completed <div class="v-visible-sr">Moderator tag</div></a>
61+
<span class="s-tag s-tag__moderator">status-bydesign <div class="v-visible-sr">Moderator tag</div><button class="s-tag--dismiss"><span class="v-visible-sr">Dismiss status-bydesign tag</span>@Svg.ClearSm</button></span>
62+
<a class="s-tag s-tag__moderator" href="#">status-planned <div class="v-visible-sr">Moderator tag</div></a>
5763
{% endhighlight %}
5864
<div class="stacks-preview--example">
5965
<div class="d-flex g4 fw-wrap">
60-
<a class="s-tag s-tag__moderator" href="#">status-completed</a>
61-
<span class="s-tag s-tag__moderator">status-bydesign <button class="s-tag--dismiss">{% icon "ClearSm" %}</button></span>
62-
<a class="s-tag s-tag__moderator" href="#">status-planned</a>
66+
<a class="s-tag s-tag__moderator" href="#">status-completed <div class="v-visible-sr">Moderator tag</div></a>
67+
<span class="s-tag s-tag__moderator">status-bydesign <div class="v-visible-sr">Moderator tag</div><button class="s-tag--dismiss"><span class="v-visible-sr">Dismiss status-bydesign tag</span>{% icon "ClearSm" %}</button></span>
68+
<a class="s-tag s-tag__moderator" href="#">status-planned <div class="v-visible-sr">Moderator tag</div></a>
6369
</div>
6470
</div>
6571
</div>
6672

67-
{% header "h3", "Required" %}
68-
<div class="stacks-preview">
73+
{% header "h3", "Required" %}
74+
<div class="stacks-preview">
6975
{% highlight html %}
70-
<a class="s-tag s-tag__required" href="#">discussion</a>
71-
<span class="s-tag s-tag__required">feature-request <button class="s-tag--dismiss">@Svg.ClearSm</button></span>
72-
<a class="s-tag s-tag__required" href="#">bug</a>
76+
<a class="s-tag s-tag__required" href="#">discussion <div class="v-visible-sr">Required tag</div></a>
77+
<span class="s-tag s-tag__required">feature-request <div class="v-visible-sr">Required tag</div><button class="s-tag--dismiss"><span class="v-visible-sr">Dismiss feature-request tag</span>@Svg.ClearSm</button></span>
78+
<a class="s-tag s-tag__required" href="#">bug <div class="v-visible-sr">Required tag</div></a>
7379
{% endhighlight %}
7480
<div class="stacks-preview--example">
7581
<div class="d-flex g4 fw-wrap">
76-
<a class="s-tag s-tag__required" href="#">discussion</a>
77-
<span class="s-tag s-tag__required">feature-request <button class="s-tag--dismiss">{% icon "ClearSm" %}</button></span>
78-
<a class="s-tag s-tag__required" href="#">bug</a>
82+
<a class="s-tag s-tag__required" href="#">discussion <div class="v-visible-sr">Required tag</div></a>
83+
<span class="s-tag s-tag__required">feature-request <div class="v-visible-sr">Required tag</div><button class="s-tag--dismiss"><span class="v-visible-sr">Dismiss feature-request tag</span>{% icon "ClearSm" %}</button></span>
84+
<a class="s-tag s-tag__required" href="#">bug <div class="v-visible-sr">Required tag</div></a>
7985
</div>
8086
</div>
8187
</div>
8288

8389
{% header "h3", "Watched" %}
84-
<div class="stacks-preview">
90+
<div class="stacks-preview">
8591
{% highlight html %}
86-
<a class="s-tag s-tag__watched" href="#">asp-net</a>
92+
<a class="s-tag s-tag__watched" href="#">asp-net <div class="v-visible-sr">Watched tag</div></a>
8793
{% endhighlight %}
88-
<div class="stacks-preview--example">
89-
<div class="d-flex g4 fw-wrap">
90-
<a class="s-tag s-tag__watched" href="#">asp-net</a>
91-
<span class="s-tag s-tag__watched s-tag__required">typescript<button class="s-tag--dismiss"><span class="v-visible-sr">Dismiss typescript tag</span>{% icon "ClearSm" %}</button></span>
92-
<a class="s-tag s-tag__watched s-tag__moderator" href="#">svelte</a>
93-
</div>
94+
<div class="stacks-preview--example">
95+
<div class="d-flex g4 fw-wrap">
96+
<a class="s-tag s-tag__watched" href="#">asp-net <div class="v-visible-sr">Watched tag</div></a>
9497
</div>
9598
</div>
99+
</div>
96100

97101
{% header "h3", "Ignored" %}
98-
<div class="stacks-preview">
102+
<div class="stacks-preview">
99103
{% highlight html %}
100-
<a class="s-tag s-tag__ignored" href="#">netscape</a>
101-
<span class="s-tag s-tag__ignored s-tag__required">rust<button class="s-tag--dismiss"><span class="v-visible-sr">Dismiss rust tag</span> @Svg.ClearSm</button></span>
102-
<a class="s-tag s-tag__ignored s-tag__moderator" href="#">swift</a>
104+
<a class="s-tag s-tag__ignored" href="#">netscape <div class="v-visible-sr">Ignored tag</div></a>
103105
{% endhighlight %}
104-
<div class="stacks-preview--example">
105-
<div class="d-flex g4 fw-wrap">
106-
<a class="s-tag s-tag__ignored" href="#">netscape</a>
107-
<span class="s-tag s-tag__ignored s-tag__required">rust<button class="s-tag--dismiss"><span class="v-visible-sr">Dismiss rust tag</span>{% icon "ClearSm" %}</button></span>
108-
<a class="s-tag s-tag__ignored s-tag__moderator" href="#">swift</a>
109-
</div>
106+
<div class="stacks-preview--example">
107+
<div class="d-flex g4 fw-wrap">
108+
<a class="s-tag s-tag__ignored" href="#">netscape <div class="v-visible-sr">Ignored tag</div></a>
110109
</div>
111110
</div>
111+
</div>
112112

113-
{% header "h3", "Deleted" %}
114-
<div class="stacks-preview">
115-
{% highlight html %}
116-
<a class="s-tag s-tag__deleted" href="#">java</a>
117-
<span class="s-tag s-tag__deleted s-tag__required">python<button class="s-tag--dismiss"><span class="v-visible-sr">Dismiss python tag</span>{% icon "ClearSm" %}</button></span>
118-
<a class="s-tag s-tag__deleted s-tag__moderator" href="#">elixir</a>
119-
{% endhighlight %}
120-
<div class="stacks-preview--example">
121-
<div class="d-flex g4 fw-wrap">
122-
<a class="s-tag s-tag__deleted" href="#">java</a>
123-
<span class="s-tag s-tag__deleted s-tag__required">python<button class="s-tag--dismiss"><span class="v-visible-sr">Dismiss python tag</span>{% icon "ClearSm" %}</button></span>
124-
<a class="s-tag s-tag__deleted s-tag__moderator" href="#">elixir</a>
125-
</div>
126-
</div>
127-
</div>
128113
</section>
129114

130115
<section class="stacks-section">
@@ -147,5 +132,4 @@
147132
{% endfor %}
148133
</tbody>
149134
</table>
150-
</section>
151-
135+
</section>

packages/stacks-svelte/src/components/Tag/Tag.svelte

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,31 @@
5151
*/
5252
i18nDismissButtonText?: string;
5353
54+
/**
55+
* Localized translation for sponsored status text
56+
*/
57+
i18nSponsorTagText?: string;
58+
59+
/**
60+
* Localized translation for watched status text
61+
*/
62+
i18nWatchedTagText?: string;
63+
64+
/**
65+
* Localized translation for ignored status text
66+
*/
67+
i18nIgnoredTagText?: string;
68+
69+
/**
70+
* Localized translation for moderator status text
71+
*/
72+
i18nModeratorTagText?: string;
73+
74+
/**
75+
* Localized translation for required status text
76+
*/
77+
i18nRequiredTagText?: string;
78+
5479
/**
5580
* Additional CSS classes added to the element
5681
*/
@@ -80,6 +105,11 @@
80105
ignored = false,
81106
watched = false,
82107
i18nDismissButtonText = "Dismiss tag",
108+
i18nSponsorTagText = "Sponsored tag",
109+
i18nWatchedTagText = "Watched tag",
110+
i18nIgnoredTagText = "Ignored tag",
111+
i18nModeratorTagText = "Moderator tag",
112+
i18nRequiredTagText = "Required tag",
83113
class: className = "",
84114
ondismiss = () => {},
85115
children,
@@ -130,14 +160,38 @@
130160
class={classes}
131161
{href}
132162
role={role || (href && "link")}
163+
tabindex={href ? undefined : 0}
133164
{...restProps}
134165
>
135166
{#if sponsor}
136167
<span class="s-tag--sponsor">
137168
{@render sponsor()}
138169
</span>
139170
{/if}
140-
{@render children()}{#if dismissable && !href}
171+
172+
{@render children()}
173+
174+
{#if sponsor}
175+
<div class="v-visible-sr">{i18nSponsorTagText}</div>
176+
{/if}
177+
178+
{#if watched}
179+
<div class="v-visible-sr">{i18nWatchedTagText}</div>
180+
{/if}
181+
182+
{#if ignored}
183+
<div class="v-visible-sr">{i18nIgnoredTagText}</div>
184+
{/if}
185+
186+
{#if variant === "moderator"}
187+
<div class="v-visible-sr">{i18nModeratorTagText}</div>
188+
{/if}
189+
190+
{#if variant === "required"}
191+
<div class="v-visible-sr">{i18nRequiredTagText}</div>
192+
{/if}
193+
194+
{#if dismissable && !href}
141195
<button class="s-tag--dismiss" type="button" onclick={ondismiss}>
142196
<span class="v-visible-sr">{i18nDismissButtonText}</span><Icon
143197
src={IconClearSm}

packages/stacks-svelte/src/components/Tag/Tag.test.ts

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ describe("Tag", () => {
1818

1919
it("should render as an anchor when href is provided", () => {
2020
render(Tag, { href: "#", children: snippet });
21-
// text renders with a leading space in to include storybook slot description
22-
expect(screen.getByRole("link")).to.have.text(" test snippet");
21+
expect(screen.getByRole("link").textContent?.trim()).to.equal(
22+
"test snippet"
23+
);
24+
expect(screen.getByRole("link")).not.to.have.attribute("tabindex"); // we only append tabindex when we use spans
2325
});
2426

2527
it("should render the appropriate size class", () => {
@@ -29,16 +31,38 @@ describe("Tag", () => {
2931
);
3032
});
3133

32-
it("should render the appropriate variant class", () => {
33-
render(Tag, { variant: "moderator", children: snippet });
34+
it("should render the all variant class", () => {
35+
let rendered = render(Tag, { variant: "moderator", children: snippet });
3436
expect(screen.getByText("test snippet").parentElement).to.have.class(
3537
"s-tag__moderator"
3638
);
39+
expect(screen.getByText("Moderator tag")).to.exist.and.to.have.class(
40+
"v-visible-sr"
41+
);
42+
43+
rendered.unmount();
44+
rendered = render(Tag, { variant: "required", children: snippet });
45+
expect(screen.getByText("test snippet").parentElement).to.have.class(
46+
"s-tag__required"
47+
);
48+
expect(screen.getByText("Required tag")).to.exist.and.to.have.class(
49+
"v-visible-sr"
50+
);
3751
});
3852

39-
it("should render the dismiss element", () => {
53+
it("should render the dismiss element as span", () => {
4054
render(Tag, { dismissable: true, children: snippet });
55+
const childElement = screen.getByText("test snippet");
56+
expect(childElement).to.exist;
57+
58+
const tagElement = childElement.parentElement;
59+
expect(tagElement?.tagName).to.equal("SPAN");
60+
expect(tagElement).to.have.attribute("tabindex", "0");
61+
4162
expect(screen.getByRole("button")).to.have.class("s-tag--dismiss");
63+
expect(screen.getByText("Dismiss tag")).to.exist.and.have.class(
64+
"v-visible-sr"
65+
);
4266
});
4367

4468
it("should not render the dismiss element if href prop is set", () => {
@@ -51,13 +75,19 @@ describe("Tag", () => {
5175
expect(screen.getByText("test snippet").parentElement).to.have.class(
5276
"s-tag__ignored"
5377
);
78+
expect(screen.getByText("Ignored tag")).to.exist.and.to.have.class(
79+
"v-visible-sr"
80+
);
5481
});
5582

5683
it("should render including the watched class", () => {
5784
render(Tag, { watched: true, children: snippet });
5885
expect(screen.getByText("test snippet").parentElement).to.have.class(
5986
"s-tag__watched"
6087
);
88+
expect(screen.getByText("Watched tag")).to.exist.and.to.have.class(
89+
"v-visible-sr"
90+
);
6191
});
6292

6393
it("should render the sponsor element", () => {
@@ -71,6 +101,9 @@ describe("Tag", () => {
71101
expect(screen.getByText("sponsor").parentElement).to.have.class(
72102
"s-tag--sponsor"
73103
);
104+
expect(screen.getByText("Sponsored tag")).to.exist.and.to.have.class(
105+
"v-visible-sr"
106+
);
74107
});
75108

76109
// events
@@ -150,12 +183,67 @@ describe("Tag", () => {
150183
});
151184

152185
// i18n
153-
it("should localize the dismiss button text when dedicated prop is specified", () => {
154-
render(Tag, {
186+
it("should localize all text when dedicated prop is specified", () => {
187+
let rendered = render(Tag, {
155188
dismissable: true,
156189
i18nDismissButtonText: "Chidui tag",
157190
children: snippet,
158191
});
159-
expect(screen.getByRole("button")).to.have.text("Chidui tag");
192+
193+
expect(screen.getByText("Chidui tag")).to.exist.and.have.class(
194+
"v-visible-sr"
195+
);
196+
197+
rendered.unmount();
198+
rendered = render(Tag, {
199+
i18nSponsorTagText: "Sponsoredo tago",
200+
sponsor: createRawSnippet(() => ({
201+
render: () => `<span>sponsor</span>`,
202+
})),
203+
children: snippet,
204+
});
205+
expect(screen.getByText("Sponsoredo tago")).to.exist.and.have.class(
206+
"v-visible-sr"
207+
);
208+
209+
rendered.unmount();
210+
rendered = render(Tag, {
211+
i18nWatchedTagText: "Vatchita tago",
212+
children: snippet,
213+
watched: true,
214+
});
215+
expect(screen.getByText("Vatchita tago")).to.exist.and.have.class(
216+
"v-visible-sr"
217+
);
218+
219+
rendered.unmount();
220+
rendered = render(Tag, {
221+
i18nIgnoredTagText: "Ignorita tago",
222+
children: snippet,
223+
ignored: true,
224+
});
225+
expect(screen.getByText("Ignorita tago")).to.exist.and.have.class(
226+
"v-visible-sr"
227+
);
228+
229+
rendered.unmount();
230+
rendered = render(Tag, {
231+
i18nModeratorTagText: "Moderatita tago",
232+
children: snippet,
233+
variant: "moderator",
234+
});
235+
expect(screen.getByText("Moderatita tago")).to.exist.and.have.class(
236+
"v-visible-sr"
237+
);
238+
239+
rendered.unmount();
240+
rendered = render(Tag, {
241+
i18nRequiredTagText: "Requisite tago",
242+
children: snippet,
243+
variant: "required",
244+
});
245+
expect(screen.getByText("Requisite tago")).to.exist.and.have.class(
246+
"v-visible-sr"
247+
);
160248
});
161249
});

0 commit comments

Comments
 (0)