Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(tooltip): announce content to assistive technology #2069

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
75fea5d
feat(tooltip): add ESC to close functionality
adamjohnson Nov 25, 2024
ee1ffe3
refactor(tooltip): rename CSS class
adamjohnson Nov 25, 2024
5a52b82
fix(tooltip): add `aria-describedby`/`id` pair
adamjohnson Nov 26, 2024
34e2a63
chore(tooltip): add changeset
adamjohnson Nov 26, 2024
5ae835d
fix(tooltip): remove slotchange handler functions
adamjohnson Dec 2, 2024
d9a66f9
fix(tooltip): use native `<button>` with `aria-describedby`/ IDREF pair
adamjohnson Dec 2, 2024
dfe4fdb
docs(tooltip): add a11y markup guidance around `<button>`/`<rh-button>`
adamjohnson Dec 2, 2024
3e12c51
fix(tooltip): attach event listener to `globalThis` for improved perf…
adamjohnson Dec 3, 2024
a46cac0
Merge branch 'main' into fix/tooltip/a11y-announce-content
adamjohnson Dec 3, 2024
776ba7a
Merge branch 'main' into fix/tooltip/a11y-announce-content
adamjohnson Dec 6, 2024
9bf1a1c
fix(tooltip): announce content
bennypowers Dec 8, 2024
ab9017e
Merge branch 'main' into fix/tooltip/a11y-announce-content
adamjohnson Dec 9, 2024
cbc5dc7
Merge branch 'main' into fix/tooltip/a11y-announce-content
bennypowers Dec 10, 2024
2fa8d8e
fix(tooltip): global announcer
bennypowers Dec 10, 2024
91d1e55
test(tooltip): ax queries
bennypowers Dec 10, 2024
9720690
fix(tooltip): surrender to compilers
bennypowers Dec 10, 2024
e684697
fix(tooltip): crossbrowser aria
bennypowers Dec 10, 2024
be61a06
docs(tooltip): remove test icon
bennypowers Dec 10, 2024
b33e509
fix(tooltip): revert regression
bennypowers Dec 10, 2024
5dfe772
perf(tooltip): shave bytes
bennypowers Dec 10, 2024
4e9dd1f
Merge branch 'main' into fix/tooltip/a11y-announce-content
adamjohnson Dec 16, 2024
357d4a1
fix(tooltip): use logical properties for announcer
adamjohnson Dec 17, 2024
e24478e
docs(tooltip): remove outdated a11y docs
adamjohnson Dec 17, 2024
a9ce72a
fix(tooltip): re-add `<rh-button>` to tooltip demos
adamjohnson Dec 17, 2024
d2bda3f
Merge branch 'main' into fix/tooltip/a11y-announce-content
adamjohnson Dec 18, 2024
ee2bdea
fix(tooltip): prune unused class
adamjohnson Dec 18, 2024
cea398a
fix(tooltip): change `aria-live` to `role: status`
adamjohnson Dec 18, 2024
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
5 changes: 5 additions & 0 deletions .changeset/brown-fans-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rhds/elements": patch
---

`<rh-tooltip>`: make tooltip content available to assistive technology
4 changes: 2 additions & 2 deletions elements/rh-tooltip/demo/bottom.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<rh-tooltip position="bottom">
<rh-button>Bottom Tooltip</rh-button>
<span slot="content">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
<button aria-describedby="tooltip-content-1">Bottom Tooltip</button>
<span id="tooltip-content-1" slot="content">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Mi eget mauris pharetra et ultrices.</span>
</rh-tooltip>

Expand Down
4 changes: 2 additions & 2 deletions elements/rh-tooltip/demo/color-context.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<rh-context-demo>
<rh-tooltip>
<rh-button>Tooltip</rh-button>
<span slot="content">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
<button aria-describedby="tooltip-content-1">Tooltip</button>
<span id="tooltip-content-1" slot="content">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Mi eget mauris pharetra et ultrices.</span>
</rh-tooltip>
</rh-context-demo>
Expand Down
9 changes: 9 additions & 0 deletions elements/rh-tooltip/demo/content-attributes.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<rh-tooltip content="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Mi eget mauris pharetra et ultrices.">
<rh-button>Tooltip</rh-button>
</rh-tooltip>

<script type="module">
import '@rhds/elements/rh-button/rh-button.js';
import '@rhds/elements/rh-tooltip/rh-tooltip.js';
</script>
4 changes: 2 additions & 2 deletions elements/rh-tooltip/demo/left.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

<p lang="he" dir="rtl" style="text-align: right;">
<rh-tooltip position="left">
<rh-button>עם ישראל חי</rh-button>
<span slot="content">
<button aria-describedby="tooltip-content-1">עם ישראל חי</button>
<span id="tooltip-content-1" slot="content">
בְּאֶרֶץ-יִשְׂרָאֵל קָם הָעָם הַיְּהוּדִי, בָּהּ עֻצְּבָה דְּמוּתוֹ הָרוּחָנִית, הַדָּתִית וְהַמְּדִינִית, בָּהּ חַי חַיֵּי קוֹמְמִיּוּת מַמְלַכְתִּית, בָּהּ יָצַר נִכְסֵי תַּרְבּוּת לְאֻמִּיִּים וּכְלַל-אֱנוֹשִׁיִּים וְהוֹרִישׁ לָעוֹלָם כֻּלּוֹ אֶת סֵפֶר הַסְּפָרִים הַנִּצְחִי.
לְאַחַר שֶׁהֻגְלָה הָעָם מֵאַרְצוֹ בְּכֹחַ הַזְּרוֹעַ שָׁמַר לָהּ אֱמוּנִים בְּכָל אַרְצוֹת פְּזוּרָיו, וְלֹא חָדַל מִתְּפִלָּה וּמִתִּקְוָה לָשׁוּב לְאַרְצוֹ וּלְחַדֵּשׁ בְּתוֹכָהּ אֶת חֵרוּתוֹ הַמְּדִינִית. </span>
</rh-tooltip>
Expand Down
1 change: 0 additions & 1 deletion elements/rh-tooltip/demo/rh-tooltip.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,3 @@
import '@rhds/elements/rh-button/rh-button.js';
import '@rhds/elements/rh-tooltip/rh-tooltip.js';
</script>

4 changes: 2 additions & 2 deletions elements/rh-tooltip/demo/right.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<rh-tooltip position="right">
<rh-button>Right Tooltip</rh-button>
<span slot="content">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
<button aria-describedby="tooltip-content-1">Right Tooltip</button>
<span id="tooltip-content-1" slot="content">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Mi eget mauris pharetra et ultrices.</span>
</rh-tooltip>

Expand Down
4 changes: 2 additions & 2 deletions elements/rh-tooltip/demo/top.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<rh-tooltip position="top">
<rh-button>Top Tooltip</rh-button>
<span slot="content">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
<button aria-describedby="tooltip-content-1">Top Tooltip</button>
<span id="tooltip-content-1" slot="content">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Mi eget mauris pharetra et ultrices.</span>
</rh-tooltip>

Expand Down
18 changes: 18 additions & 0 deletions elements/rh-tooltip/docs/40-accessibility.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
## Markup Guidance

In order to create accessible tooltips, the trigger must have an `aria-describedby` attribute and the tooltip content must have a corresponding [IDREF](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id). See the [demos](/elements/tooltip/demos/) for further implementation details.

### Should implementors use `<rh-tooltip>` with `<rh-button>`?

Implementors may want to use `<rh-tooltip>` with `<rh-button>`. Unfortunately, that results in inaccessible tooltips for the following reasons:

* In order to be accessible, the `aria-describedby` must exist on a native interactive element (eg: `<button>`, `<input>`, etc) and not a `<rh-*>` element.
* While `<rh-button>` contains a `<button>` element, this `<button>` element exists in an encapsulated shadowdom. Any attributes (like `aria-describedby`) added into the shadowdom cannot communicate with IDREFs outside of the shadowdom—and vice versa.

For these reasons, it's recommended to use native HTML elements for `<rh-tooltip>` triggers and content—paired with the appropriate `aria-describedby` and IDREF attributes.

<rh-alert state="neutral">
<h3 slot="header">Cross-root ARIA</h3>
<p>An upcoming proposal called <a href="https://leobalter.github.io/cross-root-aria-delegation/">Cross-root ARIA</a> will solve this &lt;rh-button&gt; issue.</p>
</rh-alert>

## Keyboard interactions

A tooltip will appear when the trigger receives focus and disappear when moving focus away from the trigger.
Expand Down
2 changes: 1 addition & 1 deletion elements/rh-tooltip/rh-tooltip.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
var(--rh-tooltip__content--BackgroundColor, var(--rh-color-surface-darkest, #151515)));
}

.c {
.display-c {
display: contents;
}

Expand Down
67 changes: 61 additions & 6 deletions elements/rh-tooltip/rh-tooltip.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { html, LitElement } from 'lit';
import { html, LitElement, isServer } from 'lit';
import { customElement } from 'lit/decorators/custom-element.js';
import { property } from 'lit/decorators/property.js';
import { classMap } from 'lit/directives/class-map.js';
Expand Down Expand Up @@ -43,6 +43,41 @@ export class RhTooltip extends LitElement {

static readonly styles = [styles];

private static instances = new Set<RhTooltip>();

static {
if (!isServer) {
globalThis.addEventListener('keydown', (event: KeyboardEvent) => {
const { instances } = RhTooltip;
for (const instance of instances) {
instance.#onKeydown(event);
}
});
RhTooltip.initAnnouncer();
}
}

private static announcer: HTMLElement;

private static announce(message: string) {
this.announcer.innerText = message;
}

private static initAnnouncer() {
document.body.append((this.announcer = Object.assign(document.createElement('div'), {
ariaLive: 'polite',
// apply `.visually-hidden` styles
style: /* css */`
position: fixed;
top: 0;
left: 0;
overflow: hidden;
adamjohnson marked this conversation as resolved.
Show resolved Hide resolved
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;`,
})));
}

/** The position of the tooltip, relative to the invoking content */
@property() position: Placement = 'top';

Expand All @@ -57,10 +92,22 @@ export class RhTooltip extends LitElement {

#initialized = false;

get #content() {
if (!this.#float.open || isServer) {
return '';
} else {
return this.content || (this.shadowRoot
?.getElementById('content') as HTMLSlotElement)
?.assignedNodes().map(x => x.textContent ?? '')
?.join(' ');
}
}

override connectedCallback(): void {
super.connectedCallback();
ENTER_EVENTS.forEach(evt => this.addEventListener(evt, this.show));
EXIT_EVENTS.forEach(evt => this.addEventListener(evt, this.hide));
RhTooltip.instances.add(this);
}

override render() {
Expand All @@ -71,15 +118,15 @@ export class RhTooltip extends LitElement {
<div id="container"
style="${styleMap(styles)}"
class="${classMap({ open,
'initialized': !!this.#initialized,
initialized: !!this.#initialized,
[on]: !!on,
[anchor]: !!anchor,
[alignment]: !!alignment })}">
<div class="c" role="tooltip" aria-labelledby="tooltip">
<slot id="invoker"></slot>
<div id="invoker">
<slot id="invoker-slot"></slot>
</div>
<div class="c" aria-hidden="${String(!open) as 'true' | 'false'}">
<slot id="tooltip" name="content">${this.content}</slot>
<div id="tooltip" role="status">
<slot id="content" name="content">${this.content}</slot>
</div>
</div>
`;
Expand All @@ -94,12 +141,20 @@ export class RhTooltip extends LitElement {
: { mainAxis: 15, alignmentAxis: -4 };
await this.#float.show({ offset, placement });
this.#initialized ||= true;
RhTooltip.announce(this.#content);
}

/** Hide the tooltip */
async hide() {
await this.#float.hide();
RhTooltip.announcer.innerText = '';
}

#onKeydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
this.hide();
}
};
}

declare global {
Expand Down
6 changes: 2 additions & 4 deletions elements/rh-tooltip/test/rh-tooltip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,8 @@ describe('<rh-tooltip>', function() {
beforeEach(() => sendMouseToTooltip(element));
it('content should be available to assistive technology', async function() {
await sendMouseToTooltip(element);
const snapshot = await a11ySnapshot();
expect(snapshot.children?.length).to.equal(1);
expect(snapshot.children?.at(0)?.role).to.equal('text');
expect(snapshot.children?.at(0)?.name).to.equal(content);
expect(await a11ySnapshot())
.to.have.axQuery({ role: 'text', name: content });
});
});
});
Expand Down
Loading