From dfe1905e7c61007decb27da4dc30ea17fb1de1b1 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Mon, 25 May 2020 09:42:49 +0200 Subject: [PATCH 1/2] 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, }; } From 3f84a3bab88cd470007f5a327cfd999d65076992 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Sat, 6 Jun 2020 15:15:09 +0200 Subject: [PATCH 2/2] feat(tooltip): add invoker relation for accessibility --- packages/tooltip/README.md | 18 +++++++++++++++ packages/tooltip/src/LionTooltip.js | 19 ++++++++++++++-- packages/tooltip/test/lion-tooltip.test.js | 26 ++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/tooltip/README.md b/packages/tooltip/README.md index 4d70f7fb6..579f60b5f 100644 --- a/packages/tooltip/README.md +++ b/packages/tooltip/README.md @@ -80,6 +80,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` + + + +
Agenda
+ +`; +``` + ### Placements You can easily change the placement of the content node relative to the invoker. diff --git a/packages/tooltip/src/LionTooltip.js b/packages/tooltip/src/LionTooltip.js index b9bb45d5b..c7cd02db2 100644 --- a/packages/tooltip/src/LionTooltip.js +++ b/packages/tooltip/src/LionTooltip.js @@ -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,8 +142,9 @@ export class LionTooltip extends OverlayMixin(LitElement) { this.__syncFromPopperState(data); }, }, - isTooltip: true, handlesAccessibility: true, + isTooltip: true, + invokerRelation: this.invokerRelation, }; } diff --git a/packages/tooltip/test/lion-tooltip.test.js b/packages/tooltip/test/lion-tooltip.test.js index b3a2374b5..8a0852536 100644 --- a/packages/tooltip/test/lion-tooltip.test.js +++ b/packages/tooltip/test/lion-tooltip.test.js @@ -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` + +
Hey there
+ +
+ `); + 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` + +
Hey there
+ +
+ `); + 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`