Merge pull request #726 from ing-bank/fix/tooltipA11y
[tooltip] accessibility fixes
This commit is contained in:
commit
bc45c655e3
7 changed files with 286 additions and 46 deletions
|
|
@ -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`
|
||||||
|
<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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,27 +351,58 @@ export class OverlayController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
__initAccessibility() {
|
__setupTeardownAccessibility({ phase }) {
|
||||||
// TODO: remove a11y attributes on teardown
|
if (phase === 'init') {
|
||||||
if (!this.contentNode.id) {
|
this.__storeOriginalAttrs(this.contentNode, ['role', 'id']);
|
||||||
this.contentNode.setAttribute('id', this._contentId);
|
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(
|
__storeOriginalAttrs(node, attrs) {
|
||||||
this.invokerRelation === 'label' ? 'aria-labelledby' : 'aria-describedby',
|
const attrMap = {};
|
||||||
this._contentId,
|
attrs.forEach(attrName => {
|
||||||
);
|
attrMap[attrName] = node.getAttribute(attrName);
|
||||||
}
|
});
|
||||||
this.contentNode.setAttribute('role', 'tooltip');
|
this.__originalAttrs.set(node, attrMap);
|
||||||
} else {
|
}
|
||||||
if (this.invokerNode) {
|
|
||||||
this.invokerNode.setAttribute('aria-expanded', this.isShown);
|
__restorOriginalAttrs() {
|
||||||
}
|
for (const [node, attrMap] of this.__originalAttrs) {
|
||||||
if (!this.contentNode.role) {
|
Object.entries(attrMap).forEach(([attrName, value]) => {
|
||||||
this.contentNode.setAttribute('role', 'dialog');
|
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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,12 @@ import { simulateTab } from '../src/utils/simulate-tab.js';
|
||||||
|
|
||||||
const withGlobalTestConfig = () => ({
|
const withGlobalTestConfig = () => ({
|
||||||
placementMode: 'global',
|
placementMode: 'global',
|
||||||
contentNode: fixtureSync(html` <div>my content</div> `),
|
contentNode: fixtureSync(html`<div>my content</div>`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const withLocalTestConfig = () => ({
|
const withLocalTestConfig = () => ({
|
||||||
placementMode: 'local',
|
placementMode: 'local',
|
||||||
contentNode: fixtureSync(html` <div>my content</div> `),
|
contentNode: fixtureSync(html`<div>my content</div>`),
|
||||||
invokerNode: fixtureSync(html`
|
invokerNode: fixtureSync(html`
|
||||||
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
|
<div role="button" style="width: 100px; height: 20px;">Invoker</div>
|
||||||
`),
|
`),
|
||||||
|
|
@ -134,7 +134,7 @@ describe('OverlayController', () => {
|
||||||
it.skip('creates local target next to sibling for placement mode "local"', async () => {
|
it.skip('creates local target next to sibling for placement mode "local"', async () => {
|
||||||
const ctrl = new OverlayController({
|
const ctrl = new OverlayController({
|
||||||
...withLocalTestConfig(),
|
...withLocalTestConfig(),
|
||||||
invokerNode: await fixture(html` <button>Invoker</button> `),
|
invokerNode: await fixture(html`<button>Invoker</button>`),
|
||||||
});
|
});
|
||||||
expect(ctrl._renderTarget).to.be.undefined;
|
expect(ctrl._renderTarget).to.be.undefined;
|
||||||
expect(ctrl.content).to.equal(ctrl.invokerNode.nextElementSibling);
|
expect(ctrl.content).to.equal(ctrl.invokerNode.nextElementSibling);
|
||||||
|
|
@ -293,7 +293,7 @@ describe('OverlayController', () => {
|
||||||
});
|
});
|
||||||
await ctrl.show();
|
await ctrl.show();
|
||||||
|
|
||||||
const elOutside = await fixture(html` <button>click me</button> `);
|
const elOutside = await fixture(html`<button>click me</button>`);
|
||||||
const input1 = ctrl.contentNode.querySelectorAll('input')[0];
|
const input1 = ctrl.contentNode.querySelectorAll('input')[0];
|
||||||
const input2 = ctrl.contentNode.querySelectorAll('input')[1];
|
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 () => {
|
it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => {
|
||||||
const contentNode = await fixture(html` <div><input /></div> `);
|
const contentNode = await fixture(html`<div><input /></div>`);
|
||||||
|
|
||||||
const ctrl = new OverlayController({
|
const ctrl = new OverlayController({
|
||||||
...withGlobalTestConfig(),
|
...withGlobalTestConfig(),
|
||||||
|
|
@ -316,10 +316,10 @@ describe('OverlayController', () => {
|
||||||
trapsKeyboardFocus: true,
|
trapsKeyboardFocus: true,
|
||||||
});
|
});
|
||||||
// add element to dom to allow focus
|
// add element to dom to allow focus
|
||||||
await fixture(html` ${ctrl.content} `);
|
await fixture(html`${ctrl.content}`);
|
||||||
await ctrl.show();
|
await ctrl.show();
|
||||||
|
|
||||||
const elOutside = await fixture(html` <input /> `);
|
const elOutside = await fixture(html`<input />`);
|
||||||
const input = ctrl.contentNode.querySelector('input');
|
const input = ctrl.contentNode.querySelector('input');
|
||||||
|
|
||||||
input.focus();
|
input.focus();
|
||||||
|
|
@ -524,7 +524,7 @@ describe('OverlayController', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('works with 3rd party code using "event.stopPropagation()" on capture phase', async () => {
|
it('works with 3rd party code using "event.stopPropagation()" on capture phase', async () => {
|
||||||
const invokerNode = await fixture(html` <div role="button">Invoker</div> `);
|
const invokerNode = await fixture(html`<div role="button">Invoker</div>`);
|
||||||
const contentNode = await fixture('<div>Content</div>');
|
const contentNode = await fixture('<div>Content</div>');
|
||||||
const ctrl = new OverlayController({
|
const ctrl = new OverlayController({
|
||||||
...withLocalTestConfig(),
|
...withLocalTestConfig(),
|
||||||
|
|
@ -1011,7 +1011,7 @@ describe('OverlayController', () => {
|
||||||
it('reinitializes content', async () => {
|
it('reinitializes content', async () => {
|
||||||
const ctrl = new OverlayController({
|
const ctrl = new OverlayController({
|
||||||
...withLocalTestConfig(),
|
...withLocalTestConfig(),
|
||||||
contentNode: await fixture(html` <div>content1</div> `),
|
contentNode: await fixture(html`<div>content1</div>`),
|
||||||
});
|
});
|
||||||
await ctrl.show(); // Popper adds inline styles
|
await ctrl.show(); // Popper adds inline styles
|
||||||
expect(ctrl.content.style.transform).not.to.be.undefined;
|
expect(ctrl.content.style.transform).not.to.be.undefined;
|
||||||
|
|
@ -1019,13 +1019,13 @@ describe('OverlayController', () => {
|
||||||
|
|
||||||
ctrl.updateConfig({
|
ctrl.updateConfig({
|
||||||
placementMode: 'local',
|
placementMode: 'local',
|
||||||
contentNode: await fixture(html` <div>content2</div> `),
|
contentNode: await fixture(html`<div>content2</div>`),
|
||||||
});
|
});
|
||||||
expect(ctrl.contentNode.textContent).to.include('content2');
|
expect(ctrl.contentNode.textContent).to.include('content2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('respects the initial config provided to new OverlayController(initialConfig)', async () => {
|
it('respects the initial config provided to new OverlayController(initialConfig)', async () => {
|
||||||
const contentNode = fixtureSync(html` <div>my content</div> `);
|
const contentNode = fixtureSync(html`<div>my content</div>`);
|
||||||
|
|
||||||
const ctrl = new OverlayController({
|
const ctrl = new OverlayController({
|
||||||
// This is the shared config
|
// This is the shared config
|
||||||
|
|
@ -1045,7 +1045,7 @@ describe('OverlayController', () => {
|
||||||
|
|
||||||
// Currently not working, enable again when we fix updateConfig
|
// Currently not working, enable again when we fix updateConfig
|
||||||
it.skip('allows for updating viewport config placement only, while keeping the content shown', async () => {
|
it.skip('allows for updating viewport config placement only, while keeping the content shown', async () => {
|
||||||
const contentNode = fixtureSync(html` <div>my content</div> `);
|
const contentNode = fixtureSync(html`<div>my content</div>`);
|
||||||
|
|
||||||
const ctrl = new OverlayController({
|
const ctrl = new OverlayController({
|
||||||
// This is the shared config
|
// This is the shared config
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,24 @@ import '@lion/tooltip/lion-tooltip.js';
|
||||||
|
|
||||||
## Examples
|
## 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`
|
||||||
|
<style>
|
||||||
|
${tooltipDemoStyles}
|
||||||
|
</style>
|
||||||
|
<lion-tooltip .config=${{ invokerRelation: 'label' }}>
|
||||||
|
<button slot="invoker" class="demo-tooltip-invoker">📅</button>
|
||||||
|
<div slot="content" class="demo-tooltip-content">Agenda<div>
|
||||||
|
</lion-tooltip>
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
### Placements
|
### Placements
|
||||||
|
|
||||||
You can easily change the placement of the content node relative to the invoker.
|
You can easily change the placement of the content node relative to the invoker.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ export class LionTooltip extends OverlayMixin(LitElement) {
|
||||||
reflect: true,
|
reflect: true,
|
||||||
attribute: 'has-arrow',
|
attribute: 'has-arrow',
|
||||||
},
|
},
|
||||||
|
invokerRelation: {
|
||||||
|
type: String,
|
||||||
|
attribute: 'invoker-relation',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +74,17 @@ export class LionTooltip extends OverlayMixin(LitElement) {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
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._mouseActive = false;
|
||||||
this._keyActive = false;
|
this._keyActive = false;
|
||||||
this.__setupRepositionCompletePromise();
|
this.__setupRepositionCompletePromise();
|
||||||
|
|
@ -79,7 +94,6 @@ export class LionTooltip extends OverlayMixin(LitElement) {
|
||||||
if (super.connectedCallback) {
|
if (super.connectedCallback) {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
}
|
}
|
||||||
this._overlayContentNode.setAttribute('role', 'tooltip');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
@ -128,6 +142,9 @@ export class LionTooltip extends OverlayMixin(LitElement) {
|
||||||
this.__syncFromPopperState(data);
|
this.__syncFromPopperState(data);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
handlesAccessibility: true,
|
||||||
|
isTooltip: true,
|
||||||
|
invokerRelation: this.invokerRelation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,32 @@ describe('lion-tooltip', () => {
|
||||||
expect(content.getAttribute('role')).to.be.equal('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`
|
||||||
|
<lion-tooltip>
|
||||||
|
<div slot="content">Hey there</div>
|
||||||
|
<button slot="invoker">Tooltip button</button>
|
||||||
|
</lion-tooltip>
|
||||||
|
`);
|
||||||
|
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`
|
||||||
|
<lion-tooltip invoker-relation="label">
|
||||||
|
<div slot="content">Hey there</div>
|
||||||
|
<button slot="invoker">Tooltip button</button>
|
||||||
|
</lion-tooltip>
|
||||||
|
`);
|
||||||
|
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 () => {
|
it('should be accessible when closed', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-tooltip>
|
<lion-tooltip>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue