-
Notifications
You must be signed in to change notification settings - Fork 4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
document.blockingElements polyfill with inert #1
Changes from all commits
2ee1dc9
1a58d42
65d9f44
01bf3bd
8a0346a
ff302a6
526ebb5
d0f4dbf
02dd4bc
8577af6
d6f7ac3
7947475
81f054e
b9ba9b8
55a48e6
b29db53
9f964aa
23a905e
3c9cccc
d1fee38
feaf2be
fd5769c
3541b98
f53dbc8
4949ca8
3312844
b3ce161
410ac57
cf89f3f
9e267e4
8b1651a
db8a53a
e4d46d5
1a33482
3fcc78c
c8e0120
19178ea
601b323
0dcb7da
9c0d667
d9b10ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
node_modules | ||
bower_components |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
language: node_js | ||
node_js: stable | ||
dist: trusty | ||
sudo: required | ||
addons: | ||
firefox: latest | ||
apt: | ||
sources: | ||
- google-chrome | ||
packages: | ||
- google-chrome-stable | ||
before_script: | ||
- npm install -g bower polylint web-component-tester | ||
- bower install | ||
- polylint | ||
script: xvfb-run wct |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,37 @@ | ||
# `blockingElements` stack API | ||
|
||
Implementation of proposal https://github.com/whatwg/html/issues/897 | ||
Implementation of proposal <https://github.com/whatwg/html/issues/897> | ||
|
||
`document.$blockingElements` manages a stack of elements that inert the interaction outside them. | ||
|
||
- the stack can be updated with the methods `push(elem), remove(elem), pop(elem)` | ||
- the top element (`document.$blockingElements.top`) is the interactive part of the document | ||
- `has(elem)` returns if the element is a blocking element | ||
|
||
This polyfill will: | ||
|
||
- search for the path of the element to block up to `document.body` | ||
- set `inert` to all the siblings of each parent, skipping the parents and the element's distributed content (if any) | ||
|
||
Use this polyfill together with the [WICG/inert](https://github.com/WICG/inert) polyfill to disable interactions on the rest of the document. See the [demo page]() as an example. | ||
|
||
## Why not listening to events that trigger focus change? | ||
|
||
Another approach could be to listen for events that trigger focus change (e.g. `focus, blur, keydown`) and prevent those if focus moves out of the blocking element. | ||
|
||
Wrapping the focus requires to find all the focusable nodes within the top blocking element, eventually sort by tabindex, in order to find first and last focusable node. | ||
|
||
This approach doesn't allow the focus to move outside the window (e.g. to the browser's url bar, dev console if opened, etc.), and is less robust when used with assistive technology (e.g. android talkback allows to move focus with swipe on screen, Apple Voiceover allows to move focus with special keyboard combinations). | ||
|
||
## Performance | ||
|
||
Performance is dependent on the `inert` polyfill performance. The polyfill tries to invoke `inert` only if strictly needed (e.g. avoid setting it twice when updating the top blocking element). | ||
|
||
At each toggle, scripting + rendering + painting totals to **~50ms** (first toggle), **~35ms** (next toggles) | ||
|
||
The heaviest parts are: | ||
|
||
- unconditional paint caused by changes to `cursor-event` css property (see [issue](https://github.com/WICG/inert/issues/21)) => once fixed we should gain **~20-25ms** | ||
- addition of the inert style nodes in shadow roots (done once for inert element's shadow root, see [polyfill's implementation](https://github.com/WICG/inert/blob/master/inert.js#L581)) => can be fixed only by native implementation of `inert`, should be a gain of at least **~5ms** (cost of adding a node) | ||
|
||
The results have been obtained by toggling the deepest `x-trap-focus` inside nested `x-b` (Chrome v52 stable for MacOs -> <http://localhost:8080/components/blockingElements/demo/ce.html?ce=v0>) ![results](https://cloud.githubusercontent.com/assets/6173664/17538133/914f365a-5e57-11e6-9b91-1c6b7eb22d57.png) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
<script src="blocking-elements.js"></script> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,335 @@ | ||
/** | ||
* | ||
* Copyright 2016 Google Inc. All rights reserved. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
(function(document) { | ||
|
||
/* Symbols for private properties */ | ||
const _blockingElements = Symbol(); | ||
const _alreadyInertElements = Symbol(); | ||
|
||
/* Symbols for private static methods */ | ||
const _topChanged = Symbol(); | ||
const _setInertToSiblingsOfElement = Symbol(); | ||
const _getParents = Symbol(); | ||
const _getDistributedChildren = Symbol(); | ||
const _isInertable = Symbol(); | ||
const _isInert = Symbol(); | ||
const _setInert = Symbol(); | ||
|
||
/** | ||
* `BlockingElements` manages a stack of elements that inert the interaction | ||
* outside them. The top element is the interactive part of the document. | ||
* The stack can be updated with the methods `push, remove, pop`. | ||
*/ | ||
class BlockingElements { | ||
constructor() { | ||
|
||
/** | ||
* The blocking elements. | ||
* @type {Array<HTMLElement>} | ||
* @private | ||
*/ | ||
this[_blockingElements] = []; | ||
|
||
/** | ||
* Elements that are already inert before the first blocking element is pushed. | ||
* @type {Set<HTMLElement>} | ||
* @private | ||
*/ | ||
this[_alreadyInertElements] = new Set(); | ||
} | ||
|
||
/** | ||
* Call this whenever this object is about to become obsolete. This empties | ||
* the blocking elements | ||
*/ | ||
destructor() { | ||
// Pretend like top changed from current top to null in order to reset | ||
// all its parents inertness. Ensure we keep inert what was already inert! | ||
BlockingElements[_topChanged](null, this.top, this[_alreadyInertElements]); | ||
this[_blockingElements] = null; | ||
this[_alreadyInertElements] = null; | ||
} | ||
|
||
/** | ||
* The top blocking element. | ||
* @type {HTMLElement|null} | ||
*/ | ||
get top() { | ||
const elems = this[_blockingElements]; | ||
return elems[elems.length - 1] || null; | ||
} | ||
|
||
/** | ||
* Adds the element to the blocking elements. | ||
* @param {!HTMLElement} element | ||
*/ | ||
push(element) { | ||
if (this.has(element)) { | ||
console.warn('element already added in document.blockingElements'); | ||
return; | ||
} | ||
BlockingElements[_topChanged](element, this.top, this[_alreadyInertElements]); | ||
this[_blockingElements].push(element); | ||
} | ||
|
||
/** | ||
* Removes the element from the blocking elements. Returns true if the element | ||
* was removed. | ||
* @param {!HTMLElement} element | ||
* @returns {boolean} | ||
*/ | ||
remove(element) { | ||
const i = this[_blockingElements].indexOf(element); | ||
if (i === -1) { | ||
return false; | ||
} | ||
this[_blockingElements].splice(i, 1); | ||
// Top changed only if the removed element was the top element. | ||
if (i === this[_blockingElements].length) { | ||
BlockingElements[_topChanged](this.top, element, this[_alreadyInertElements]); | ||
} | ||
return true; | ||
} | ||
|
||
/** | ||
* Remove the top blocking element and returns it. | ||
* @returns {HTMLElement|null} the removed element. | ||
*/ | ||
pop() { | ||
const top = this.top; | ||
top && this.remove(top); | ||
return top; | ||
} | ||
|
||
/** | ||
* Returns if the element is a blocking element. | ||
* @param {!HTMLElement} element | ||
* @returns {boolean} | ||
*/ | ||
has(element) { | ||
return this[_blockingElements].indexOf(element) !== -1; | ||
} | ||
|
||
/** | ||
* Sets `inert` to all document elements except the new top element, its parents, | ||
* and its distributed content. Pass `oldTop` to limit element updates (will look | ||
* for common parents and avoid setting them twice). | ||
* When the first blocking element is added (`newTop = null`), it saves the elements | ||
* that are already inert into `alreadyInertElems`. When the last blocking element | ||
* is removed (`oldTop = null`), `alreadyInertElems` are kept inert. | ||
* @param {HTMLElement} newTop If null, it means the last blocking element was removed. | ||
* @param {HTMLElement} oldTop If null, it means the first blocking element was added. | ||
* @param {!Set<HTMLElement>} alreadyInertElems Elements to be kept inert. | ||
* @private | ||
*/ | ||
static[_topChanged](newTop, oldTop, alreadyInertElems) { | ||
const oldElParents = oldTop ? this[_getParents](oldTop) : []; | ||
const newElParents = newTop ? this[_getParents](newTop) : []; | ||
const elemsToSkip = newTop && newTop.shadowRoot ? | ||
this[_getDistributedChildren](newTop.shadowRoot) : null; | ||
// Loop from top to deepest elements, so we find the common parents and | ||
// avoid setting them twice. | ||
while (oldElParents.length || newElParents.length) { | ||
const oldElParent = oldElParents.pop(); | ||
const newElParent = newElParents.pop(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The tools team is leaning in the direction of only using At the risk of starting a 🔥 /cc @justinfagnani @rictic There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leaning, pending final disposition of a gentlemanly duel of ideas between the tools team :) I personally prefer There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. synced offline, and the agreement is to use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
if (oldElParent === newElParent) { | ||
continue; | ||
} | ||
// Same parent, set only these 2 children. | ||
if (oldElParent && newElParent && | ||
oldElParent.parentNode === newElParent.parentNode) { | ||
if (!oldTop && this[_isInert](oldElParent)) { | ||
alreadyInertElems.add(oldElParent); | ||
} | ||
this[_setInert](oldElParent, true); | ||
this[_setInert](newElParent, alreadyInertElems.has(newElParent)); | ||
} else { | ||
oldElParent && this[_setInertToSiblingsOfElement](oldElParent, false, elemsToSkip, | ||
alreadyInertElems); | ||
// Collect the already inert elements only if it is the first blocking | ||
// element (if oldTop = null) | ||
newElParent && this[_setInertToSiblingsOfElement](newElParent, true, elemsToSkip, | ||
oldTop ? null : alreadyInertElems); | ||
} | ||
} | ||
if (!newTop) { | ||
alreadyInertElems.clear(); | ||
} | ||
} | ||
|
||
/** | ||
* Returns if the element is not inertable. | ||
* @param {!HTMLElement} element | ||
* @returns {boolean} | ||
* @private | ||
*/ | ||
static[_isInertable](element) { | ||
return /^(style|template|script)$/.test(element.localName); | ||
} | ||
|
||
/** | ||
* Sets `inert` to the siblings of the element except the elements to skip. | ||
* If `inert = true`, already inert elements are added into `alreadyInertElems`. | ||
* If `inert = false`, siblings that are contained in `alreadyInertElems` will | ||
* be kept inert. | ||
* @param {!HTMLElement} element | ||
* @param {boolean} inert | ||
* @param {Set<HTMLElement>} elemsToSkip | ||
* @param {Set<HTMLElement>} alreadyInertElems | ||
* @private | ||
*/ | ||
static[_setInertToSiblingsOfElement](element, inert, elemsToSkip, alreadyInertElems) { | ||
// Previous siblings. | ||
let sibling = element; | ||
while ((sibling = sibling.previousElementSibling)) { | ||
// If not inertable or to be skipped, skip. | ||
if (this[_isInertable](sibling) || (elemsToSkip && elemsToSkip.has(sibling))) { | ||
continue; | ||
} | ||
// Should be collected since already inerted. | ||
if (alreadyInertElems && inert && this[_isInert](sibling)) { | ||
alreadyInertElems.add(sibling); | ||
} | ||
// Should be kept inert if it's in `alreadyInertElems`. | ||
this[_setInert](sibling, inert || (alreadyInertElems && alreadyInertElems.has(sibling))); | ||
} | ||
// Next siblings. | ||
sibling = element; | ||
while ((sibling = sibling.nextElementSibling)) { | ||
// If not inertable or to be skipped, skip. | ||
if (this[_isInertable](sibling) || (elemsToSkip && elemsToSkip.has(sibling))) { | ||
continue; | ||
} | ||
// Should be collected since already inerted. | ||
if (alreadyInertElems && inert && this[_isInert](sibling)) { | ||
alreadyInertElems.add(sibling); | ||
} | ||
// Should be kept inert if it's in `alreadyInertElems`. | ||
this[_setInert](sibling, inert || (alreadyInertElems && alreadyInertElems.has(sibling))); | ||
} | ||
} | ||
|
||
/** | ||
* Returns the list of parents of an element, starting from element (included) | ||
* up to `document.body` (excluded). | ||
* @param {!HTMLElement} element | ||
* @returns {Array<HTMLElement>} | ||
* @private | ||
*/ | ||
static[_getParents](element) { | ||
const parents = []; | ||
let current = element; | ||
// Stop to body. | ||
while (current && current !== document.body) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The syntax for this loop might be able to be tightened up a bit? let current = element;
do {
// ...
} while (current = current.parentNode || current.host) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, there are multiple conditions, so maybe my proposal would just be uglier.. |
||
// Skip shadow roots. | ||
if (current.nodeType === Node.ELEMENT_NODE) { | ||
parents.push(current); | ||
} | ||
// ShadowDom v1 | ||
if (current.assignedSlot) { | ||
// Collect slots from deepest slot to top. | ||
while ((current = current.assignedSlot)) { | ||
parents.push(current); | ||
} | ||
// Continue the search on the top slot. | ||
current = parents.pop(); | ||
continue; | ||
} | ||
// ShadowDom v0 | ||
const insertionPoints = current.getDestinationInsertionPoints ? | ||
current.getDestinationInsertionPoints() : []; | ||
if (insertionPoints.length) { | ||
for (let i = 0; i < insertionPoints.length - 1; i++) { | ||
parents.push(current); | ||
} | ||
// Continue the search on the top content. | ||
current = insertionPoints[insertionPoints.length - 1]; | ||
continue; | ||
} | ||
current = current.parentNode || current.host; | ||
} | ||
return parents; | ||
} | ||
|
||
/** | ||
* Returns the distributed children of a shadow root. | ||
* @param {!DocumentFragment} shadowRoot | ||
* @returns {Set<HTMLElement>} | ||
* @private | ||
*/ | ||
static[_getDistributedChildren](shadowRoot) { | ||
const result = new Set(); | ||
let i, j, nodes; | ||
// ShadowDom v1 | ||
const slots = shadowRoot.querySelectorAll('slot'); | ||
if (slots.length && slots[0].assignedNodes) { | ||
for (i = 0; i < slots.length; i++) { | ||
nodes = slots[i].assignedNodes({ | ||
flatten: true | ||
}); | ||
for (j = 0; j < nodes.length; j++) { | ||
if (nodes[j].nodeType === Node.ELEMENT_NODE) { | ||
result.add(nodes[j]); | ||
} | ||
} | ||
} | ||
// No need to search for <content>. | ||
return result; | ||
} | ||
// ShadowDom v0 | ||
const contents = shadowRoot.querySelectorAll('content'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it possible to skip this check entirely if you already found slots in this shadow root? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In an hybrid scenario where we both have "working" and and their |
||
if (contents.length && contents[0].getDistributedNodes) { | ||
for (i = 0; i < contents.length; i++) { | ||
nodes = contents[i].getDistributedNodes(); | ||
for (j = 0; j < nodes.length; j++) { | ||
if (nodes[j].nodeType === Node.ELEMENT_NODE) { | ||
result.add(nodes[j]); | ||
} | ||
} | ||
} | ||
} | ||
return result; | ||
} | ||
|
||
/** | ||
* Returns if an element is inert. | ||
* @param {!HTMLElement} element | ||
* @returns {boolean} | ||
* @private | ||
*/ | ||
static[_isInert](element) { | ||
return element.inert; | ||
} | ||
|
||
/** | ||
* Sets inert to an element. | ||
* @param {!HTMLElement} element | ||
* @param {boolean} inert | ||
* @private | ||
*/ | ||
static[_setInert](element, inert) { | ||
// Prefer setting the property over the attribute since the inert spec | ||
// doesn't specify if it should be reflected. | ||
// https://html.spec.whatwg.org/multipage/interaction.html#inert | ||
element.inert = inert; | ||
} | ||
} | ||
|
||
document.$blockingElements = new BlockingElements(); | ||
|
||
})(document); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we should start using @class annotations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
are
@class
annotations actually useful on classes?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no, they're redundant for Closure. You don't need them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is that documented somewhere?