fix(overlays): accessibility attrs setup/teardown

This commit is contained in:
Thijs Louisse 2020-05-25 09:42:49 +02:00 committed by qa46hx
parent eb02b27010
commit dfe1905e7c
5 changed files with 227 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`.
@ -306,7 +344,7 @@ Features:
> Popper strictly is scoped on positioning. **It does not change the dimensions of the content node nor the invoker node**. > Popper strictly is scoped on positioning. **It does not change the dimensions of the content node nor the invoker node**.
> This also means that if you use the arrow feature, you are in charge of styling it properly, use the x-placement attribute for this. > This also means that if you use the arrow feature, you are in charge of styling it properly, use the x-placement attribute for this.
> An example implementation can be found in [lion-tooltip](?path=/docs/overlays-tooltip), where an arrow is set by default. > An example implementation can be found in [lion-tooltip](?path=/docs/overlays-tooltip--main#tooltip), where an arrow is set by default.
To override the default options we set for local mode, you add a `popperConfig` object to the config passed to the OverlayController. To override the default options we set for local mode, you add a `popperConfig` object to the config passed to the OverlayController.
Here's a succinct overview of some often used popper properties: Here's a succinct overview of some often used popper properties:

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

@ -128,6 +128,8 @@ export class LionTooltip extends OverlayMixin(LitElement) {
this.__syncFromPopperState(data); this.__syncFromPopperState(data);
}, },
}, },
isTooltip: true,
handlesAccessibility: true,
}; };
} }