From dfe1905e7c61007decb27da4dc30ea17fb1de1b1 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Mon, 25 May 2020 09:42:49 +0200 Subject: [PATCH] fix(overlays): accessibility attrs setup/teardown --- .../overlays/docs/40-system-configuration.md | 42 +++++- packages/overlays/src/OverlayController.js | 100 ++++++++++---- packages/overlays/src/utils/typedef.js | 1 + .../overlays/test/OverlayController.test.js | 128 +++++++++++++++--- packages/tooltip/src/LionTooltip.js | 2 + 5 files changed, 227 insertions(+), 46 deletions(-) diff --git a/packages/overlays/docs/40-system-configuration.md b/packages/overlays/docs/40-system-configuration.md index fdd16d88c..6fe6cc705 100644 --- a/packages/overlays/docs/40-system-configuration.md +++ b/packages/overlays/docs/40-system-configuration.md @@ -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 don’t +- Dialogs have interactive content, tooltips don’t +- 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` + + +
+ Hello! +
+
+ `; +}; +``` + ## 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 Popper.js 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`. @@ -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**. > 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. Here's a succinct overview of some often used popper properties: diff --git a/packages/overlays/src/OverlayController.js b/packages/overlays/src/OverlayController.js index 3c787ef57..64d8c819d 100644 --- a/packages/overlays/src/OverlayController.js +++ b/packages/overlays/src/OverlayController.js @@ -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,27 +351,58 @@ export class OverlayController { } } - __initAccessibility() { - // TODO: remove a11y attributes on teardown - if (!this.contentNode.id) { - this.contentNode.setAttribute('id', this._contentId); + __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); + } + if (this.isTooltip) { + if (this.invokerNode) { + this.invokerNode.setAttribute( + this.invokerRelation === 'label' ? 'aria-labelledby' : 'aria-describedby', + this._contentId, + ); + } + this.contentNode.setAttribute('role', 'tooltip'); + } else { + if (this.invokerNode) { + this.invokerNode.setAttribute('aria-expanded', this.isShown); + } + if (!this.contentNode.role) { + this.contentNode.setAttribute('role', 'dialog'); + } + } + } else if (phase === 'teardown') { + this.__restorOriginalAttrs(); } - if (this.isTooltip) { - if (this.invokerNode) { - this.invokerNode.setAttribute( - this.invokerRelation === 'label' ? 'aria-labelledby' : 'aria-describedby', - this._contentId, - ); - } - this.contentNode.setAttribute('role', 'tooltip'); - } else { - if (this.invokerNode) { - this.invokerNode.setAttribute('aria-expanded', this.isShown); - } - if (!this.contentNode.role) { - this.contentNode.setAttribute('role', 'dialog'); - } + } + + __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'); } diff --git a/packages/overlays/src/utils/typedef.js b/packages/overlays/src/utils/typedef.js index 38a3a2990..0861e79de 100644 --- a/packages/overlays/src/utils/typedef.js +++ b/packages/overlays/src/utils/typedef.js @@ -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 diff --git a/packages/overlays/test/OverlayController.test.js b/packages/overlays/test/OverlayController.test.js index b577c9e25..df692d2d6 100644 --- a/packages/overlays/test/OverlayController.test.js +++ b/packages/overlays/test/OverlayController.test.js @@ -18,12 +18,12 @@ import { simulateTab } from '../src/utils/simulate-tab.js'; const withGlobalTestConfig = () => ({ placementMode: 'global', - contentNode: fixtureSync(html`
my content
`), + contentNode: fixtureSync(html`
my content
`), }); const withLocalTestConfig = () => ({ placementMode: 'local', - contentNode: fixtureSync(html`
my content
`), + contentNode: fixtureSync(html`
my content
`), invokerNode: fixtureSync(html`
Invoker
`), @@ -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` `), + invokerNode: await fixture(html``), }); 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` `); + const elOutside = await fixture(html``); 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`
`); + const contentNode = await fixture(html`
`); 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` `); + const elOutside = await fixture(html``); 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`
Invoker
`); + const invokerNode = await fixture(html`
Invoker
`); const contentNode = await fixture('
Content
'); const ctrl = new OverlayController({ ...withLocalTestConfig(), @@ -1011,7 +1011,7 @@ describe('OverlayController', () => { it('reinitializes content', async () => { const ctrl = new OverlayController({ ...withLocalTestConfig(), - contentNode: await fixture(html`
content1
`), + contentNode: await fixture(html`
content1
`), }); 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`
content2
`), + contentNode: await fixture(html`
content2
`), }); expect(ctrl.contentNode.textContent).to.include('content2'); }); it('respects the initial config provided to new OverlayController(initialConfig)', async () => { - const contentNode = fixtureSync(html`
my content
`); + const contentNode = fixtureSync(html`
my content
`); 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`
my content
`); + const contentNode = fixtureSync(html`
my content
`); 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('
invoker
'); 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('
invoker
'); + 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('
invoker
'); + const contentNode = await fixture('
content
'); + 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('
invoker
'); + const contentNode = await fixture('
content
'); + 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('
invoker
'); + const contentNode = await fixture('
content
'); + 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', + ); }); }); }); diff --git a/packages/tooltip/src/LionTooltip.js b/packages/tooltip/src/LionTooltip.js index a4b587cf6..b9bb45d5b 100644 --- a/packages/tooltip/src/LionTooltip.js +++ b/packages/tooltip/src/LionTooltip.js @@ -128,6 +128,8 @@ export class LionTooltip extends OverlayMixin(LitElement) { this.__syncFromPopperState(data); }, }, + isTooltip: true, + handlesAccessibility: true, }; }