Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/real-beers-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sl-design-system/tooltip': patch
---

Fixes the tooltip to make it working with `aria-labelledby` as well (e.g. when an icon only button with a tooltip is used).
69 changes: 69 additions & 0 deletions packages/components/tooltip/src/tooltip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,73 @@ describe('sl-tooltip', () => {
expect(tooltip.matches(':popover-open')).to.be.true;
});
});

describe('Tooltip lazy()', () => {
let el: HTMLElement;
let button: Button;
let tooltip: Tooltip;

beforeEach(async () => {
el = await fixture(html`
<div style="display: block; width: 400px; height: 400px;">
<sl-button>Button</sl-button>
</div>
`);
button = el.querySelector('sl-button') as Button;
});

it('should create a tooltip lazily on pointerover with default aria-describedby', async () => {
Tooltip.lazy(button, createdTooltip => (tooltip = createdTooltip));

button.dispatchEvent(new Event('pointerover', { bubbles: true }));

// Give some time for the tooltip to open
await new Promise(resolve => setTimeout(resolve));

expect(tooltip).to.exist;
expect(tooltip!.id).to.match(/sl-tooltip-(\d+)/);
expect(button).to.have.attribute('aria-describedby', tooltip?.id);
expect(button).not.to.have.attribute('aria-labelledby');
expect(tooltip!.matches(':popover-open')).to.be.true;
});

it('should create a tooltip lazily on focusin', async () => {
Tooltip.lazy(button, createdTooltip => (tooltip = createdTooltip));

button.dispatchEvent(new Event('focusin', { bubbles: true }));

// Give some time for the tooltip to open
await new Promise(resolve => setTimeout(resolve));

expect(tooltip).to.exist;
expect(button).to.have.attribute('aria-describedby', tooltip?.id);
expect(tooltip!.matches(':popover-open')).to.be.true;
});

it('should use aria-labelledby when ariaRelation is label', async () => {
Tooltip.lazy(button, createdTooltip => (tooltip = createdTooltip), { ariaRelation: 'label' });

button.dispatchEvent(new Event('pointerover', { bubbles: true }));

// Give some time for the tooltip to open
await new Promise(resolve => setTimeout(resolve));

expect(tooltip).to.exist;
expect(button).to.have.attribute('aria-labelledby', tooltip?.id);
expect(button).not.to.have.attribute('aria-describedby');
});

it('should only create the tooltip once', async () => {
Tooltip.lazy(button, createdTooltip => (tooltip = createdTooltip));

button.dispatchEvent(new Event('pointerover', { bubbles: true }));
button.dispatchEvent(new Event('pointerover', { bubbles: true })); // second should be ignored

// Give some time for the tooltip to open
await new Promise(resolve => setTimeout(resolve));

expect(el.querySelectorAll('sl-tooltip').length).to.equal(1);
expect(tooltip).to.exist;
});
});
});
20 changes: 20 additions & 0 deletions packages/components/tooltip/src/tooltip.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,26 @@ export const NestedChildren: Story = {
}
};

export const IconButton: Story = {
render: () => {
return html`
<style>
#root-inner {
display: grid;
height: calc(20rem);
place-items: center;
}
</style>
<sl-button aria-labelledby="tooltip" variant="primary" fill="solid" shape="pill" size="md">
<sl-icon name="face-smile"></sl-icon>
</sl-button>
<sl-tooltip id="tooltip" position="top">
This is the tooltip message that labels the icon only button.
</sl-tooltip>
`;
}
};

export const All: Story = {
render: () => {
setTimeout(() => {
Expand Down
16 changes: 15 additions & 1 deletion packages/components/tooltip/src/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ export interface TooltipOptions {
* it will be added next to the anchor element.
*/
parentNode?: Node;

/**
* Which ARIA relationship attribute to add to the anchor (`aria-describedby` or `aria-labelledby`).
* Defaults to 'description' ('aria-describedby').
*
* A good example of when to use `aria-labelledby`
* is when the tooltip provides a label or title for the anchor element,
* such as an icon only button (so button with only an icon) and no visible text.
*/
ariaRelation?: 'description' | 'label';
}

let nextUniqueId = 0;
Expand Down Expand Up @@ -79,7 +89,11 @@ export class Tooltip extends LitElement {
}

tooltip.id = `sl-tooltip-${nextUniqueId++}`;
target.setAttribute('aria-describedby', tooltip.id);

const ariaRelation = options.ariaRelation ?? 'description',
ariaAttribute = ariaRelation === 'label' ? 'labelledby' : 'describedby';

target.setAttribute(`aria-${ariaAttribute}`, tooltip.id);

callback(tooltip);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ Here's an overview of the common keyboard interactions associated with a tooltip

{{ 'aria-attributes' | recurringText }}

A tooltip can be linked to another element by either using `aria-describedby` or `aria-labelledby` attributes. The choice between the two depends on the context and the relationship between the tooltip and the anchor element.
A tooltip can be linked to another element by either using `aria-describedby` or `aria-labelledby` attributes.
The choice between the two depends on the context and the relationship between the tooltip and the anchor element.
A good example of when to use `aria-labelledby` is when the tooltip provides a label or title for the anchor element,
such as an icon only button (so button with only an icon) and no visible text.
In this case, the tooltip serves as the accessible name for the button.

You can read more on the difference between the two attributes in the [MDN article about aria-describedby](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-describedby#:~:text=The%20aria%2Ddescribedby%20attribute%20is%20very%20similar%20to%20the)

Expand Down