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 ## trapsKeyboardFocus
Boolean property. When true, the focus will rotate through the **focusable elements** inside the `contentNode`. 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') ## 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`. > 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, hidesOnOutsideEsc: false,
hidesOnOutsideClick: false, hidesOnOutsideClick: false,
isTooltip: false, isTooltip: false,
invokerRelation: 'description',
handlesUserInteraction: false, handlesUserInteraction: false,
handlesAccessibility: false, handlesAccessibility: false,
popperConfig: { popperConfig: {
@ -134,7 +135,7 @@ export class OverlayController {
this.manager.add(this); this.manager.add(this);
this._contentId = `overlay-content--${Math.random().toString(36).substr(2, 10)}`; this._contentId = `overlay-content--${Math.random().toString(36).substr(2, 10)}`;
this.__originalAttrs = new Map();
if (this._defaultConfig.contentNode) { if (this._defaultConfig.contentNode) {
if (!this._defaultConfig.contentNode.isConnected) { if (!this._defaultConfig.contentNode.isConnected) {
throw new Error( throw new Error(
@ -236,18 +237,32 @@ export class OverlayController {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
__validateConfiguration(newConfig) { __validateConfiguration(newConfig) {
if (!newConfig.placementMode) { 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)) { if (!['global', 'local'].includes(newConfig.placementMode)) {
throw new Error( 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) { 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) { 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) { // if (newConfig.popperConfig.modifiers.arrow && !newConfig.contentWrapperNode) {
// throw new Error('You need to provide a .contentWrapperNode when Popper arrow is enabled'); // throw new Error('You need to provide a .contentWrapperNode when Popper arrow is enabled');
@ -257,9 +272,6 @@ export class OverlayController {
async _init({ cfgToAdd }) { async _init({ cfgToAdd }) {
this.__initcontentWrapperNode({ cfgToAdd }); this.__initcontentWrapperNode({ cfgToAdd });
this.__initConnectionTarget(); this.__initConnectionTarget();
if (this.handlesAccessibility) {
this.__initAccessibility({ cfgToAdd });
}
if (this.placementMode === 'local') { if (this.placementMode === 'local') {
// Lazily load Popper if not done yet // Lazily load Popper if not done yet
@ -339,8 +351,15 @@ export class OverlayController {
} }
} }
__initAccessibility() { __setupTeardownAccessibility({ phase }) {
// TODO: remove a11y attributes on teardown if (phase === 'init') {
this.__storeOriginalAttrs(this.contentNode, ['role', 'id']);
this.__storeOriginalAttrs(this.invokerNode, [
'aria-expanded',
'aria-labelledby',
'aria-describedby',
]);
if (!this.contentNode.id) { if (!this.contentNode.id) {
this.contentNode.setAttribute('id', this._contentId); this.contentNode.setAttribute('id', this._contentId);
} }
@ -360,6 +379,30 @@ export class OverlayController {
this.contentNode.setAttribute('role', 'dialog'); 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() { get isShown() {
@ -772,6 +815,9 @@ export class OverlayController {
} }
_handleAccessibility({ phase }) { _handleAccessibility({ phase }) {
if (phase === 'init' || phase === 'teardown') {
this.__setupTeardownAccessibility({ phase });
}
if (this.invokerNode && !this.isTooltip) { if (this.invokerNode && !this.isTooltip) {
this.invokerNode.setAttribute('aria-expanded', phase === 'show'); this.invokerNode.setAttribute('aria-expanded', phase === 'show');
} }

View file

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

View file

@ -1071,7 +1071,7 @@ describe('OverlayController', () => {
}); });
describe('Accessibility', () => { 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 invokerNode = await fixture('<div role="button">invoker</div>');
const ctrl = new OverlayController({ const ctrl = new OverlayController({
...withLocalTestConfig(), ...withLocalTestConfig(),
@ -1232,6 +1232,66 @@ describe('OverlayController', () => {
}); });
expect(ctrl.contentNode.getAttribute('role')).to.equal('tooltip'); 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({ new OverlayController({
contentNode, 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 () => { it('throws if invalid .placementMode gets passed on', async () => {
@ -1252,7 +1312,9 @@ describe('OverlayController', () => {
new OverlayController({ new OverlayController({
placementMode: 'invalid', 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 () => { it('throws if no .contentNode gets passed on', async () => {
@ -1260,7 +1322,7 @@ describe('OverlayController', () => {
new OverlayController({ new OverlayController({
placementMode: 'global', 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 () => { it('throws if contentNodewrapper is not provided for projected contentNode', async () => {
@ -1284,7 +1346,39 @@ describe('OverlayController', () => {
...withLocalTestConfig(), ...withLocalTestConfig(),
contentNode, 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 ## 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 ### Placements
You can easily change the placement of the content node relative to the invoker. 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, reflect: true,
attribute: 'has-arrow', attribute: 'has-arrow',
}, },
invokerRelation: {
type: String,
attribute: 'invoker-relation',
},
}; };
} }
@ -70,6 +74,17 @@ export class LionTooltip extends OverlayMixin(LitElement) {
constructor() { constructor() {
super(); 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._mouseActive = false;
this._keyActive = false; this._keyActive = false;
this.__setupRepositionCompletePromise(); this.__setupRepositionCompletePromise();
@ -79,7 +94,6 @@ export class LionTooltip extends OverlayMixin(LitElement) {
if (super.connectedCallback) { if (super.connectedCallback) {
super.connectedCallback(); super.connectedCallback();
} }
this._overlayContentNode.setAttribute('role', 'tooltip');
} }
render() { render() {
@ -128,6 +142,9 @@ export class LionTooltip extends OverlayMixin(LitElement) {
this.__syncFromPopperState(data); 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'); 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 () => { it('should be accessible when closed', async () => {
const el = await fixture(html` const el = await fixture(html`
<lion-tooltip> <lion-tooltip>