Skip to content
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

Merged
merged 41 commits into from
Sep 9, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
2ee1dc9
initial implementation with inert
valdrinkoshi Jul 22, 2016
1a58d42
docs
valdrinkoshi Jul 22, 2016
65d9f44
get path to body, then inert all siblings. Complex examples added
valdrinkoshi Jul 22, 2016
01bf3bd
avoid setting common parents twice on top change
valdrinkoshi Jul 23, 2016
8a0346a
update jsdocs, remove has method
valdrinkoshi Jul 25, 2016
ff302a6
simpler distributed children
valdrinkoshi Jul 25, 2016
526ebb5
fix jsdocs
valdrinkoshi Jul 25, 2016
d0f4dbf
proper use of const and let
valdrinkoshi Jul 25, 2016
02dd4bc
docs for the class. Call topChanged only if it actually did change
valdrinkoshi Jul 25, 2016
8577af6
keep track of the already inert elements
valdrinkoshi Jul 25, 2016
d6f7ac3
fix types in jsdocs. Check if alreadyInertElems is null
valdrinkoshi Jul 25, 2016
7947475
log toggle time
valdrinkoshi Jul 25, 2016
81f054e
no vars in test page
valdrinkoshi Jul 25, 2016
b9ba9b8
add apache license to blocking-elements.html. Add class annotation. N…
valdrinkoshi Jul 25, 2016
55a48e6
rename blockingElements. Use customElements v1 (polyfill)
valdrinkoshi Jul 25, 2016
b29db53
Preserve already inert elements in destructor
valdrinkoshi Jul 26, 2016
9f964aa
Static private methods. Use Symbols for internal variables
valdrinkoshi Jul 26, 2016
23a905e
symbols for static methods
valdrinkoshi Jul 26, 2016
3c9cccc
separate setInert and isInert into methods
valdrinkoshi Aug 1, 2016
d1fee38
add tests
valdrinkoshi Aug 1, 2016
feaf2be
support only slots
valdrinkoshi Aug 3, 2016
fd5769c
use bower and wct
valdrinkoshi Aug 6, 2016
3541b98
travis yaml
valdrinkoshi Aug 8, 2016
f53dbc8
support ShadowDom v0 too
valdrinkoshi Aug 8, 2016
4949ca8
travis without sauce
valdrinkoshi Aug 8, 2016
3312844
add logger for perf measurements
valdrinkoshi Aug 8, 2016
b3ce161
dynamic imports
valdrinkoshi Aug 8, 2016
410ac57
no class annotation. no use of delete.
valdrinkoshi Aug 9, 2016
cf89f3f
delete logger.html. Update copyright year.
valdrinkoshi Aug 9, 2016
9e267e4
set attribute instead of setting js property
valdrinkoshi Aug 10, 2016
8b1651a
no import, use script
valdrinkoshi Aug 10, 2016
db8a53a
lower case private constants
valdrinkoshi Aug 11, 2016
e4d46d5
convert _blockingElements to Set, keep track of _topElement. Changed …
valdrinkoshi Aug 11, 2016
1a33482
destructor should keep already inert elements still inert
valdrinkoshi Aug 11, 2016
3fcc78c
get new top before calling topChanged
valdrinkoshi Aug 11, 2016
c8e0120
Array much faster than Set to keep track of the top element (faster r…
valdrinkoshi Aug 30, 2016
19178ea
proper check if ShadowDom v0/v1 methods are available
valdrinkoshi Sep 8, 2016
601b323
early return if slots are found
valdrinkoshi Sep 8, 2016
0dcb7da
prefer setting property to attribute to align with inert spec
valdrinkoshi Sep 9, 2016
9c0d667
update README.md, load inert script on head
valdrinkoshi Sep 9, 2016
d9b10ce
update tests to set property instead of attribute (align to inert spec)
valdrinkoshi Sep 9, 2016
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
node_modules
bower_components
16 changes: 16 additions & 0 deletions .travis.yml
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
36 changes: 35 additions & 1 deletion README.md
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)
1 change: 1 addition & 0 deletions blocking-elements.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<script src="blocking-elements.js"></script>
335 changes: 335 additions & 0 deletions blocking-elements.js
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`.
Copy link

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

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?

Copy link

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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed 👍

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that documented somewhere?

*/
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();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tools team is leaning in the direction of only using const on top-level things (modules and symbols, for example), and preferring let for most things that have a relatively confined scope. What do you think of potentially standardizing on this style?

At the risk of starting a 🔥 /cc @justinfagnani @rictic

Choose a reason for hiding this comment

The 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 let for local variables.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

synced offline, and the agreement is to use const when using a variable that won't be reassigned, and let for variables that will be reused within the scope.

Copy link

Choose a reason for hiding this comment

The 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) {
Copy link

Choose a reason for hiding this comment

The 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)

Copy link

Choose a reason for hiding this comment

The 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');
Copy link

Choose a reason for hiding this comment

The 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?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In an hybrid scenario where we both have "working" and and their assignedNodes and getDistributedNodes methods, it might be worth to search for children in both of them. But I would say let's prefer v1 over v0! Updated in line 291 with an early return 👍

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);
Loading