diff --git a/packages/overlays/docs/40-system-configuration.md b/packages/overlays/docs/40-system-configuration.md
index a9c8a9d73..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`.
diff --git a/packages/overlays/src/OverlayController.js b/packages/overlays/src/OverlayController.js
index 5f2337859..02dbba656 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`
`),
});
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('
');
+ 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/README.md b/packages/tooltip/README.md
index dcbea8665..251dfd79d 100644
--- a/packages/tooltip/README.md
+++ b/packages/tooltip/README.md
@@ -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`
+
+
+
+
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 a4b587cf6..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,6 +142,9 @@ export class LionTooltip extends OverlayMixin(LitElement) {
this.__syncFromPopperState(data);
},
},
+ 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 8a267e33b..f47c7d5a7 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`
+
+