Merge pull request #726 from ing-bank/fix/tooltipA11y

[tooltip] accessibility fixes
This commit is contained in:
gerjanvangeest 2020-06-23 13:18:07 +02:00 committed by GitHub
commit bc45c655e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 286 additions and 46 deletions

View file

@ -61,6 +61,44 @@ export const placementGlobal = () => html`
`;
```
## isTooltip (placementMode: 'local')
As specified in the [overlay rationale](/?path=/docs/overlays-system-rationale--page) there are only two official types of overlays: dialogs and tooltips. And their main differences are:
- Dialogs have a modal option, tooltips dont
- Dialogs have interactive content, tooltips dont
- Dialogs are opened via regular buttons (click/space/enter), tooltips act on focus/mouseover
Since most overlays have interactive content the default is set to dialogs. To get a tooltip, you can add `isTooltip` to the config object. This only works for local placement and it also needs to have `handlesAccessibility` activated to work.
```js preview-story
export const isTooltip = () => {
function showTooltip() {
const tooltip = document.querySelector('#tooltip');
tooltip.opened = true;
}
function hideTooltip() {
const tooltip = document.querySelector('#tooltip');
tooltip.opened = false;
}
return html`
<demo-overlay-system
id="tooltip"
.config=${{ placementMode: 'local', isTooltip: true, handlesAccessibility: true }}
>
<button slot="invoker" @mouseenter="${showTooltip}" @mouseleave="${hideTooltip}">
Hover me to open the tooltip!
</button>
<div slot="content" class="demo-overlay">
Hello!
</div>
</demo-overlay-system>
`;
};
```
## trapsKeyboardFocus
Boolean property. When true, the focus will rotate through the **focusable elements** inside the `contentNode`.
@ -295,7 +333,7 @@ export const viewportConfig = () => html`
## popperConfig for local overlays (placementMode: 'local')
For locally DOM positioned overlays that position themselves relative to their invoker, we use <a href="https://popper.js.org/" target="_blank">Popper.js</a> for positioning.
For locally DOM positioned overlays that position themselves relative to their invoker, we use [Popper.js](https://popper.js.org/) for positioning.
> In Popper, `contentNode` is often referred to as `popperElement`, and `invokerNode` is often referred to as the `referenceElement`.

View file

@ -100,6 +100,7 @@ export class OverlayController {
hidesOnOutsideEsc: false,
hidesOnOutsideClick: false,
isTooltip: false,
invokerRelation: 'description',
handlesUserInteraction: false,
handlesAccessibility: false,
popperConfig: {
@ -134,7 +135,7 @@ export class OverlayController {
this.manager.add(this);
this._contentId = `overlay-content--${Math.random().toString(36).substr(2, 10)}`;
this.__originalAttrs = new Map();
if (this._defaultConfig.contentNode) {
if (!this._defaultConfig.contentNode.isConnected) {
throw new Error(
@ -236,18 +237,32 @@ export class OverlayController {
// eslint-disable-next-line class-methods-use-this
__validateConfiguration(newConfig) {
if (!newConfig.placementMode) {
throw new Error('You need to provide a .placementMode ("global"|"local")');
throw new Error(
'[OverlayController] You need to provide a .placementMode ("global"|"local")',
);
}
if (!['global', 'local'].includes(newConfig.placementMode)) {
throw new Error(
`"${newConfig.placementMode}" is not a valid .placementMode, use ("global"|"local")`,
`[OverlayController] "${newConfig.placementMode}" is not a valid .placementMode, use ("global"|"local")`,
);
}
if (!newConfig.contentNode) {
throw new Error('You need to provide a .contentNode');
throw new Error('[OverlayController] You need to provide a .contentNode');
}
if (this.__isContentNodeProjected && !newConfig.contentWrapperNode) {
throw new Error('You need to provide a .contentWrapperNode when .contentNode is projected');
throw new Error(
'[OverlayController] You need to provide a .contentWrapperNode when .contentNode is projected',
);
}
if (newConfig.isTooltip && newConfig.placementMode !== 'local') {
throw new Error(
'[OverlayController] .isTooltip should be configured with .placementMode "local"',
);
}
if (newConfig.isTooltip && !newConfig.handlesAccessibility) {
throw new Error(
'[OverlayController] .isTooltip only takes effect when .handlesAccessibility is enabled',
);
}
// if (newConfig.popperConfig.modifiers.arrow && !newConfig.contentWrapperNode) {
// throw new Error('You need to provide a .contentWrapperNode when Popper arrow is enabled');
@ -257,9 +272,6 @@ export class OverlayController {
async _init({ cfgToAdd }) {
this.__initcontentWrapperNode({ cfgToAdd });
this.__initConnectionTarget();
if (this.handlesAccessibility) {
this.__initAccessibility({ cfgToAdd });
}
if (this.placementMode === 'local') {
// Lazily load Popper if not done yet
@ -339,8 +351,15 @@ export class OverlayController {
}
}
__initAccessibility() {
// TODO: remove a11y attributes on teardown
__setupTeardownAccessibility({ phase }) {
if (phase === 'init') {
this.__storeOriginalAttrs(this.contentNode, ['role', 'id']);
this.__storeOriginalAttrs(this.invokerNode, [
'aria-expanded',
'aria-labelledby',
'aria-describedby',
]);
if (!this.contentNode.id) {
this.contentNode.setAttribute('id', this._contentId);
}
@ -360,6 +379,30 @@ export class OverlayController {
this.contentNode.setAttribute('role', 'dialog');
}
}
} else if (phase === 'teardown') {
this.__restorOriginalAttrs();
}
}
__storeOriginalAttrs(node, attrs) {
const attrMap = {};
attrs.forEach(attrName => {
attrMap[attrName] = node.getAttribute(attrName);
});
this.__originalAttrs.set(node, attrMap);
}
__restorOriginalAttrs() {
for (const [node, attrMap] of this.__originalAttrs) {
Object.entries(attrMap).forEach(([attrName, value]) => {
if (value !== null) {
node.setAttribute(attrName, value);
} else {
node.removeAttribute(attrName);
}
});
}
this.__originalAttrs.clear();
}
get isShown() {
@ -772,6 +815,9 @@ export class OverlayController {
}
_handleAccessibility({ phase }) {
if (phase === 'init' || phase === 'teardown') {
this.__setupTeardownAccessibility({ phase });
}
if (this.invokerNode && !this.isTooltip) {
this.invokerNode.setAttribute('aria-expanded', phase === 'show');
}

View file

@ -35,6 +35,7 @@
* @property {boolean} [isTooltip=false] has a totally different interaction- and accessibility
* pattern from all other overlays. Will behave as role="tooltip" element instead of a role="dialog"
* element.
* @property {'label'|'description'} [invokerRelation='description']
* @property {boolean} [handlesAccessibility]
* For non `isTooltip`:
* - sets aria-expanded="true/false" and aria-haspopup="true" on invokerNode

View file

@ -18,12 +18,12 @@ import { simulateTab } from '../src/utils/simulate-tab.js';
const withGlobalTestConfig = () => ({
placementMode: 'global',
contentNode: fixtureSync(html` <div>my content</div> `),
contentNode: fixtureSync(html`<div>my content</div>`),
});
const withLocalTestConfig = () => ({
placementMode: 'local',
contentNode: fixtureSync(html` <div>my content</div> `),
contentNode: fixtureSync(html`<div>my content</div>`),
invokerNode: fixtureSync(html`
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
`),
@ -134,7 +134,7 @@ describe('OverlayController', () => {
it.skip('creates local target next to sibling for placement mode "local"', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
invokerNode: await fixture(html` <button>Invoker</button> `),
invokerNode: await fixture(html`<button>Invoker</button>`),
});
expect(ctrl._renderTarget).to.be.undefined;
expect(ctrl.content).to.equal(ctrl.invokerNode.nextElementSibling);
@ -293,7 +293,7 @@ describe('OverlayController', () => {
});
await ctrl.show();
const elOutside = await fixture(html` <button>click me</button> `);
const elOutside = await fixture(html`<button>click me</button>`);
const input1 = ctrl.contentNode.querySelectorAll('input')[0];
const input2 = ctrl.contentNode.querySelectorAll('input')[1];
@ -308,7 +308,7 @@ describe('OverlayController', () => {
});
it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => {
const contentNode = await fixture(html` <div><input /></div> `);
const contentNode = await fixture(html`<div><input /></div>`);
const ctrl = new OverlayController({
...withGlobalTestConfig(),
@ -316,10 +316,10 @@ describe('OverlayController', () => {
trapsKeyboardFocus: true,
});
// add element to dom to allow focus
await fixture(html` ${ctrl.content} `);
await fixture(html`${ctrl.content}`);
await ctrl.show();
const elOutside = await fixture(html` <input /> `);
const elOutside = await fixture(html`<input />`);
const input = ctrl.contentNode.querySelector('input');
input.focus();
@ -524,7 +524,7 @@ describe('OverlayController', () => {
});
it('works with 3rd party code using "event.stopPropagation()" on capture phase', async () => {
const invokerNode = await fixture(html` <div role="button">Invoker</div> `);
const invokerNode = await fixture(html`<div role="button">Invoker</div>`);
const contentNode = await fixture('<div>Content</div>');
const ctrl = new OverlayController({
...withLocalTestConfig(),
@ -1011,7 +1011,7 @@ describe('OverlayController', () => {
it('reinitializes content', async () => {
const ctrl = new OverlayController({
...withLocalTestConfig(),
contentNode: await fixture(html` <div>content1</div> `),
contentNode: await fixture(html`<div>content1</div>`),
});
await ctrl.show(); // Popper adds inline styles
expect(ctrl.content.style.transform).not.to.be.undefined;
@ -1019,13 +1019,13 @@ describe('OverlayController', () => {
ctrl.updateConfig({
placementMode: 'local',
contentNode: await fixture(html` <div>content2</div> `),
contentNode: await fixture(html`<div>content2</div>`),
});
expect(ctrl.contentNode.textContent).to.include('content2');
});
it('respects the initial config provided to new OverlayController(initialConfig)', async () => {
const contentNode = fixtureSync(html` <div>my content</div> `);
const contentNode = fixtureSync(html`<div>my content</div>`);
const ctrl = new OverlayController({
// This is the shared config
@ -1045,7 +1045,7 @@ describe('OverlayController', () => {
// Currently not working, enable again when we fix updateConfig
it.skip('allows for updating viewport config placement only, while keeping the content shown', async () => {
const contentNode = fixtureSync(html` <div>my content</div> `);
const contentNode = fixtureSync(html`<div>my content</div>`);
const ctrl = new OverlayController({
// This is the shared config
@ -1071,7 +1071,7 @@ describe('OverlayController', () => {
});
describe('Accessibility', () => {
it('adds and removes [aria-expanded] on invoker', async () => {
it('synchronizes [aria-expanded] on invoker', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>');
const ctrl = new OverlayController({
...withLocalTestConfig(),
@ -1232,6 +1232,66 @@ describe('OverlayController', () => {
});
expect(ctrl.contentNode.getAttribute('role')).to.equal('tooltip');
});
describe('Teardown', () => {
it('restores [role] on dialog content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>');
const ctrl = new OverlayController({
...withLocalTestConfig(),
handlesAccessibility: true,
invokerNode,
});
expect(ctrl.contentNode.getAttribute('role')).to.equal('dialog');
ctrl.teardown();
expect(ctrl.contentNode.getAttribute('role')).to.equal(null);
});
it('restores [role] on tooltip content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>');
const contentNode = await fixture('<div role="presentation">content</div>');
const ctrl = new OverlayController({
...withLocalTestConfig(),
handlesAccessibility: true,
isTooltip: true,
invokerNode,
contentNode,
});
expect(contentNode.getAttribute('role')).to.equal('tooltip');
ctrl.teardown();
expect(contentNode.getAttribute('role')).to.equal('presentation');
});
it('restores [aria-describedby] on content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>');
const contentNode = await fixture('<div role="presentation">content</div>');
const ctrl = new OverlayController({
...withLocalTestConfig(),
handlesAccessibility: true,
isTooltip: true,
invokerNode,
contentNode,
});
expect(invokerNode.getAttribute('aria-describedby')).to.equal(contentNode.id);
ctrl.teardown();
expect(invokerNode.getAttribute('aria-describedby')).to.equal(null);
});
it('restores [aria-labelledby] on content', async () => {
const invokerNode = await fixture('<div role="button">invoker</div>');
const contentNode = await fixture('<div role="presentation">content</div>');
const ctrl = new OverlayController({
...withLocalTestConfig(),
handlesAccessibility: true,
isTooltip: true,
invokerNode,
contentNode,
invokerRelation: 'label',
});
expect(invokerNode.getAttribute('aria-labelledby')).to.equal(contentNode.id);
ctrl.teardown();
expect(invokerNode.getAttribute('aria-labelledby')).to.equal(null);
});
});
});
});
@ -1244,7 +1304,7 @@ describe('OverlayController', () => {
new OverlayController({
contentNode,
});
}).to.throw('You need to provide a .placementMode ("global"|"local")');
}).to.throw('[OverlayController] You need to provide a .placementMode ("global"|"local")');
});
it('throws if invalid .placementMode gets passed on', async () => {
@ -1252,7 +1312,9 @@ describe('OverlayController', () => {
new OverlayController({
placementMode: 'invalid',
});
}).to.throw('"invalid" is not a valid .placementMode, use ("global"|"local")');
}).to.throw(
'[OverlayController] "invalid" is not a valid .placementMode, use ("global"|"local")',
);
});
it('throws if no .contentNode gets passed on', async () => {
@ -1260,7 +1322,7 @@ describe('OverlayController', () => {
new OverlayController({
placementMode: 'global',
});
}).to.throw('You need to provide a .contentNode');
}).to.throw('[OverlayController] You need to provide a .contentNode');
});
it('throws if contentNodewrapper is not provided for projected contentNode', async () => {
@ -1284,7 +1346,39 @@ describe('OverlayController', () => {
...withLocalTestConfig(),
contentNode,
});
}).to.throw('You need to provide a .contentWrapperNode when .contentNode is projected');
}).to.throw(
'[OverlayController] You need to provide a .contentWrapperNode when .contentNode is projected',
);
});
it('throws if placementMode is global for a tooltip', async () => {
const contentNode = document.createElement('div');
document.body.appendChild(contentNode);
expect(() => {
new OverlayController({
placementMode: 'global',
contentNode,
isTooltip: true,
handlesAccessibility: true,
});
}).to.throw(
'[OverlayController] .isTooltip should be configured with .placementMode "local"',
);
});
it('throws if handlesAccessibility is false for a tooltip', async () => {
const contentNode = document.createElement('div');
document.body.appendChild(contentNode);
expect(() => {
new OverlayController({
placementMode: 'local',
contentNode,
isTooltip: true,
handlesAccessibility: false,
});
}).to.throw(
'[OverlayController] .isTooltip only takes effect when .handlesAccessibility is enabled',
);
});
});
});

View file

@ -79,6 +79,24 @@ import '@lion/tooltip/lion-tooltip.js';
## Examples
### invokerRelation
There is a difference between tooltips used as a primary label or as a description. In most cases a button will already have its own label, so the tooltip will be used as a description with extra information, which is already set as default. Only in case of icon buttons you want to use the tooltip as the primary label. To do so you need to set the `invokerRelation` to `label`.
> For detailed information please read: [inclusive tooltips](https://inclusive-components.design/tooltips-toggletips/#inclusivetooltips).
```js preview-story
export const invokerRelation = () => html`
<style>
${tooltipDemoStyles}
</style>
<lion-tooltip .config=${{ invokerRelation: 'label' }}>
<button slot="invoker" class="demo-tooltip-invoker">📅</button>
<div slot="content" class="demo-tooltip-content">Agenda<div>
</lion-tooltip>
`;
```
### Placements
You can easily change the placement of the content node relative to the invoker.

View file

@ -12,6 +12,10 @@ export class LionTooltip extends OverlayMixin(LitElement) {
reflect: true,
attribute: 'has-arrow',
},
invokerRelation: {
type: String,
attribute: 'invoker-relation',
},
};
}
@ -70,6 +74,17 @@ export class LionTooltip extends OverlayMixin(LitElement) {
constructor() {
super();
/**
* Whether an arrow should be displayed
* @type {boolean}
*/
this.hasArrow = false;
/**
* Decides whether the tooltip invoker text should be considered a description
* (sets aria-describedby) or a label (sets aria-labelledby).
* @type {'label'\'description'}
*/
this.invokerRelation = 'description';
this._mouseActive = false;
this._keyActive = false;
this.__setupRepositionCompletePromise();
@ -79,7 +94,6 @@ export class LionTooltip extends OverlayMixin(LitElement) {
if (super.connectedCallback) {
super.connectedCallback();
}
this._overlayContentNode.setAttribute('role', 'tooltip');
}
render() {
@ -128,6 +142,9 @@ export class LionTooltip extends OverlayMixin(LitElement) {
this.__syncFromPopperState(data);
},
},
handlesAccessibility: true,
isTooltip: true,
invokerRelation: this.invokerRelation,
};
}

View file

@ -206,6 +206,32 @@ describe('lion-tooltip', () => {
expect(content.getAttribute('role')).to.be.equal('tooltip');
});
it('should have aria-describedby role set on the invoker', async () => {
const el = await fixture(html`
<lion-tooltip>
<div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button>
</lion-tooltip>
`);
const content = el.querySelector('[slot=content]');
const invoker = el.querySelector('[slot=invoker]');
expect(invoker.getAttribute('aria-describedby')).to.be.equal(content.id);
expect(invoker.getAttribute('aria-labelledby')).to.be.equal(null);
});
it('should have aria-labelledby role set on the invoker when [ invoker-relation="label"]', async () => {
const el = await fixture(html`
<lion-tooltip invoker-relation="label">
<div slot="content">Hey there</div>
<button slot="invoker">Tooltip button</button>
</lion-tooltip>
`);
const content = el.querySelector('[slot=content]');
const invoker = el.querySelector('[slot=invoker]');
expect(invoker.getAttribute('aria-describedby')).to.be.equal(null);
expect(invoker.getAttribute('aria-labelledby')).to.be.equal(content.id);
});
it('should be accessible when closed', async () => {
const el = await fixture(html`
<lion-tooltip>