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`
`),
});
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('