Skip to content

Commit

Permalink
document.blockingElements polyfill with inert (#1)
Browse files Browse the repository at this point in the history
* initial implementation with inert

* docs

* get path to body, then inert all siblings. Complex examples added

* avoid setting common parents twice on top change

* update jsdocs, remove has method

* simpler distributed children

* fix jsdocs

* proper use of const and let

* docs for the class. Call topChanged only if it actually did change

* keep track of the already inert elements

* fix types in jsdocs. Check if alreadyInertElems is null

* log toggle time

* no vars in test page

* add apache license to blocking-elements.html. Add class annotation. Neater distributed children

* rename blockingElements. Use customElements v1 (polyfill)

* Preserve already inert elements in destructor

* Static private methods. Use Symbols for internal variables

* symbols for static methods

* separate setInert and isInert into methods

* add tests

* support only slots

* use bower and wct

* travis yaml

* support ShadowDom v0 too

* travis without sauce

* add logger for perf measurements

* dynamic imports

* no class annotation. no use of delete.

* delete logger.html. Update copyright year.

* set attribute instead of setting js property

* no import, use script

* lower case private constants

* convert _blockingElements to Set, keep track of _topElement. Changed  to  function

* destructor should keep already inert elements still inert

* get new top before calling topChanged

* Array much faster than Set to keep track of the top element (faster remove())

* proper check if ShadowDom v0/v1 methods are available

* early return if slots are found

* prefer setting property to attribute to align with inert spec

* update README.md, load inert script on head

* update tests to set property instead of attribute (align to inert spec)
  • Loading branch information
valdrinkoshi authored Sep 9, 2016
1 parent 8993847 commit cc23e53
Show file tree
Hide file tree
Showing 19 changed files with 4,749 additions and 25 deletions.
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`.
*/
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();
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) {
// 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');
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

0 comments on commit cc23e53

Please sign in to comment.