@lion/overlays
Advanced tools
Comparing version 0.16.4 to 0.16.5
@@ -6,2 +6,13 @@ # Change Log | ||
## [0.16.5](https://github.com/ing-bank/lion/compare/@lion/overlays@0.16.4...@lion/overlays@0.16.5) (2020-06-23) | ||
### Bug Fixes | ||
* **overlays:** accessibility attrs setup/teardown ([dfe1905](https://github.com/ing-bank/lion/commit/dfe1905e7c61007decb27da4dc30ea17fb1de1b1)) | ||
## [0.16.4](https://github.com/ing-bank/lion/compare/@lion/overlays@0.16.3...@lion/overlays@0.16.4) (2020-06-18) | ||
@@ -8,0 +19,0 @@ |
@@ -64,2 +64,40 @@ [//]: # 'AUTO INSERT HEADER PREPUBLISH' | ||
## 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 | ||
@@ -299,3 +337,3 @@ | ||
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. | ||
@@ -302,0 +340,0 @@ > In Popper, `contentNode` is often referred to as `popperElement`, and `invokerNode` is often referred to as the `referenceElement`. |
{ | ||
"name": "@lion/overlays", | ||
"version": "0.16.4", | ||
"version": "0.16.5", | ||
"description": "Overlays System using lit-html for rendering", | ||
@@ -46,3 +46,3 @@ "license": "MIT", | ||
}, | ||
"gitHead": "8958b2fa02d3b0c39120d405c5284aa01990a524" | ||
"gitHead": "a217b8a1286477157ce6c7a8c5495158537e798d" | ||
} |
@@ -103,2 +103,3 @@ import '@lion/core/src/differentKeyEventNamesShimIE.js'; | ||
isTooltip: false, | ||
invokerRelation: 'description', | ||
handlesUserInteraction: false, | ||
@@ -138,3 +139,3 @@ handlesAccessibility: false, | ||
this._contentId = `overlay-content--${Math.random().toString(36).substr(2, 10)}`; | ||
this.__originalAttrs = new Map(); | ||
if (this._defaultConfig.contentNode) { | ||
@@ -241,15 +242,29 @@ if (!this._defaultConfig.contentNode.isConnected) { | ||
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) { | ||
@@ -263,5 +278,2 @@ // throw new Error('You need to provide a .contentWrapperNode when Popper arrow is enabled'); | ||
this.__initConnectionTarget(); | ||
if (this.handlesAccessibility) { | ||
this.__initAccessibility({ cfgToAdd }); | ||
} | ||
@@ -346,25 +358,56 @@ if (this.placementMode === 'local') { | ||
__initAccessibility() { | ||
// TODO: remove a11y attributes on teardown | ||
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, | ||
); | ||
__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); | ||
} | ||
this.contentNode.setAttribute('role', 'tooltip'); | ||
} else { | ||
if (this.invokerNode) { | ||
this.invokerNode.setAttribute('aria-expanded', this.isShown); | ||
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'); | ||
} | ||
} | ||
if (!this.contentNode.role) { | ||
this.contentNode.setAttribute('role', 'dialog'); | ||
} | ||
} else if (phase === 'teardown') { | ||
this.__restorOriginalAttrs(); | ||
} | ||
} | ||
__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() { | ||
@@ -780,2 +823,5 @@ return Boolean(this._contentWrapperNode.style.display !== 'none'); | ||
_handleAccessibility({ phase }) { | ||
if (phase === 'init' || phase === 'teardown') { | ||
this.__setupTeardownAccessibility({ phase }); | ||
} | ||
if (this.invokerNode && !this.isTooltip) { | ||
@@ -782,0 +828,0 @@ this.invokerNode.setAttribute('aria-expanded', phase === 'show'); |
@@ -38,2 +38,3 @@ /** | ||
* element. | ||
* @property {'label'|'description'} [invokerRelation='description'] | ||
* @property {boolean} [handlesAccessibility] | ||
@@ -40,0 +41,0 @@ * For non `isTooltip`: |
@@ -21,3 +21,3 @@ /* eslint-disable no-new */ | ||
placementMode: 'global', | ||
contentNode: fixtureSync(html` <div>my content</div> `), | ||
contentNode: fixtureSync(html`<div>my content</div>`), | ||
}); | ||
@@ -27,3 +27,3 @@ | ||
placementMode: 'local', | ||
contentNode: fixtureSync(html` <div>my content</div> `), | ||
contentNode: fixtureSync(html`<div>my content</div>`), | ||
invokerNode: fixtureSync(html` | ||
@@ -139,3 +139,3 @@ <div role="button" style="width: 100px; height: 20px;">Invoker</div> | ||
...withLocalTestConfig(), | ||
invokerNode: await fixture(html` <button>Invoker</button> `), | ||
invokerNode: await fixture(html`<button>Invoker</button>`), | ||
}); | ||
@@ -299,3 +299,3 @@ expect(ctrl._renderTarget).to.be.undefined; | ||
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]; | ||
@@ -315,3 +315,3 @@ const input2 = ctrl.contentNode.querySelectorAll('input')[1]; | ||
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>`); | ||
@@ -324,6 +324,6 @@ const ctrl = new OverlayController({ | ||
// 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` <input /> `); | ||
const elOutside = await fixture(html`<input />`); | ||
const input = ctrl.contentNode.querySelector('input'); | ||
@@ -533,3 +533,3 @@ | ||
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>'); | ||
@@ -1021,3 +1021,3 @@ const ctrl = new OverlayController({ | ||
...withLocalTestConfig(), | ||
contentNode: await fixture(html` <div>content1</div> `), | ||
contentNode: await fixture(html`<div>content1</div>`), | ||
}); | ||
@@ -1030,3 +1030,3 @@ await ctrl.show(); // Popper adds inline styles | ||
placementMode: 'local', | ||
contentNode: await fixture(html` <div>content2</div> `), | ||
contentNode: await fixture(html`<div>content2</div>`), | ||
}); | ||
@@ -1037,3 +1037,3 @@ expect(ctrl.contentNode.textContent).to.include('content2'); | ||
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>`); | ||
@@ -1058,3 +1058,3 @@ const ctrl = new OverlayController({ | ||
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>`); | ||
@@ -1085,3 +1085,3 @@ const ctrl = new OverlayController({ | ||
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>'); | ||
@@ -1247,2 +1247,62 @@ const ctrl = new OverlayController({ | ||
}); | ||
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); | ||
}); | ||
}); | ||
}); | ||
@@ -1260,3 +1320,3 @@ }); | ||
}); | ||
}).to.throw('You need to provide a .placementMode ("global"|"local")'); | ||
}).to.throw('[OverlayController] You need to provide a .placementMode ("global"|"local")'); | ||
}); | ||
@@ -1269,3 +1329,5 @@ | ||
}); | ||
}).to.throw('"invalid" is not a valid .placementMode, use ("global"|"local")'); | ||
}).to.throw( | ||
'[OverlayController] "invalid" is not a valid .placementMode, use ("global"|"local")', | ||
); | ||
}); | ||
@@ -1278,3 +1340,3 @@ | ||
}); | ||
}).to.throw('You need to provide a .contentNode'); | ||
}).to.throw('[OverlayController] You need to provide a .contentNode'); | ||
}); | ||
@@ -1303,5 +1365,37 @@ | ||
}); | ||
}).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', | ||
); | ||
}); | ||
}); | ||
}); |
239929
4420