@servicenow/ui-renderer-snabbdom
Version:
Snabbdom renderer for UI Framework on Next Experience
253 lines (208 loc) • 9.91 kB
JavaScript
/**
* Copyright (c) 2020 ServiceNow, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import accessibilityTargets from './accessibilityTargets';
import ariaRefAssignments from './ariaRefAssignments';
function accessibilityDecorator(patchFn) {
return function patchWrapped(prev, next, options = {}) {
accessibilityTargets.push([]);
ariaRefAssignments.push([]);
const vnode = patchFn(prev, next);
const accessibilityTargetItems = accessibilityTargets.pop();
const assignments = ariaRefAssignments.pop();
const ariaRefToAssignment = new Map();
for (let i = 0; i < assignments.length; i++) {
const ariaRefAssignment = assignments[i];
ariaRefToAssignment.set(ariaRefAssignment.ariaRef, ariaRefAssignment); // Since vnodes are handled in DFS order, we know that a parent of an ariaRef will also be before
// it in the assignments array. We can use this knowledge to efficiently figure out
// each ariaRef's parent ariaRef
if (i > 0) {
let possibleParentAriaRef = assignments[i - 1].ariaRef;
while (possibleParentAriaRef) {
if (possibleParentAriaRef.contains(ariaRefAssignment.element)) {
possibleParentAriaRef.setChildQuerySelector(ariaRefAssignment.ariaRef, buildQuerySelector(ariaRefToAssignment.get(possibleParentAriaRef).element, ariaRefAssignment.element));
ariaRefAssignment.ariaRef.setParentAriaRef(possibleParentAriaRef);
break;
}
possibleParentAriaRef = possibleParentAriaRef.getParentAriaRef();
}
}
ariaRefAssignment.ariaRef.setCurrent(ariaRefAssignment.element);
}
const shadowRootToAccessibilityTargetsItems = new Map();
for (const item of accessibilityTargetItems) {
if (options.disconnect) {
if (item.currentClone && item.currentClone.parentNode) {
item.currentClone.parentNode.removeChild(item.currentClone);
item.currentClone = null;
}
if (item.callback) {
item.targetRef.unsubscribe({
element: item.element,
attribute: item.ariaAttribute
});
item.callback = null;
}
continue;
}
const callback = function callback(targetRef) {
if (!targetRef || !targetRef.isSet()) {
if (item.currentClone && item.currentClone.parentNode) {
item.currentClone.parentNode.removeChild(item.currentClone);
item.currentClone = null;
}
item.element.removeAttribute(item.ariaAttribute);
return;
}
if (item.element.getRootNode() === targetRef.getRootNode()) {
const id = targetRef.getAttribute('id');
item.element.setAttribute(item.ariaAttribute, id || item.id);
if (!id) targetRef.setId(item.id);
return;
} // Check if there are other ariaRefs within accessibilityTargetItems that are in the same shadow root
// If so, queue them, so they can be handled all at once after all their callbacks have been called
const shadowRoot = targetRef.getRootNode();
const targetItemsWithSharedShadow = accessibilityTargetItems.filter(t => t.targetRef.isSet() && t.targetRef.getRootNode() === shadowRoot);
let targetItemsSharedShadowQueue = shadowRootToAccessibilityTargetsItems.get(shadowRoot);
if (!targetItemsSharedShadowQueue) {
targetItemsSharedShadowQueue = new Set();
shadowRootToAccessibilityTargetsItems.set(shadowRoot, targetItemsSharedShadowQueue);
}
targetItemsSharedShadowQueue.add(item);
if (targetItemsSharedShadowQueue.size === targetItemsWithSharedShadow.length) {
handleAccessibilityTargets(targetItemsSharedShadowQueue);
shadowRootToAccessibilityTargetsItems.delete(shadowRoot);
}
}; // Subscribe for any changes to the aria ref so we can update the cloned node
item.callback = callback;
item.targetRef.subscribe({
element: item.element,
attribute: item.ariaAttribute
}, callback);
}
return vnode;
};
}
function handleAccessibilityTargets(targetItemsShadowQueue) {
// Separate ariaRefs that do and do not have a parent.
// Those without a parent are the refs that should be cloned
const targetRefToTreeNode = new Map();
const targetItemRoots = [];
const targetItems = Array.from(targetItemsShadowQueue);
for (let i = 0; i < targetItems.length; i++) {
const targetItem = targetItems[i];
let treeNode = targetRefToTreeNode.get(targetItem.targetRef);
if (!treeNode) {
treeNode = {
targetItems: [],
children: [],
targetRef: targetItem.targetRef,
parentTargetRef: null
};
targetRefToTreeNode.set(targetItem.targetRef, treeNode);
}
treeNode.targetItems.push(targetItem);
let parentAriaRef = targetItem.targetRef.getParentAriaRef();
while (parentAriaRef) {
if (targetItems.find(item => item.targetRef === parentAriaRef)) {
let parentNode = targetRefToTreeNode.get(parentAriaRef);
if (!parentNode) {
parentNode = {
targetItems: [],
children: [],
targetRef: parentAriaRef,
parentTargetRef: null
};
targetRefToTreeNode.set(parentAriaRef, parentNode);
}
treeNode.parentTargetRef = parentAriaRef;
parentNode.children.push(targetItem);
break;
}
parentAriaRef = parentAriaRef.getParentAriaRef();
}
if (!parentAriaRef) targetItemRoots.push(targetItem);
} // Loop over targetItemRoots:
// For each one, if it hasn't been cloned yet, clone into DOM and then for each child:
// If it hasn't been handled yet, create the query selector from the child targetRef to the
// parent targetRef, then use that selector on the cloned item, then edit the returned element
// to have the correct id
const cloned = new Map();
const targetRefToQuerySelector = new Map();
for (let i = 0; i < targetItemRoots.length; i++) {
const rootTargetItem = targetItemRoots[i];
const firstItemWithTargetRef = cloned.get(rootTargetItem.targetRef);
rootTargetItem.element.setAttribute(rootTargetItem.ariaAttribute, firstItemWithTargetRef ? firstItemWithTargetRef.id : rootTargetItem.id);
if (!firstItemWithTargetRef) {
cloned.set(rootTargetItem.targetRef, rootTargetItem);
const clone = rootTargetItem.targetRef.cloneNode(true);
clone.id = rootTargetItem.id;
const container = document.createElement('div');
container.style = 'position: absolute; overflow: hidden; width: 1px; height: 1px; margin: 0; border: none; padding: 0; white-space: nowrap; clip: rect(0 0 0 0); clip-path: inset(50%);';
container.appendChild(clone);
if (rootTargetItem.currentClone && rootTargetItem.currentClone.parentNode) rootTargetItem.currentClone.parentNode.removeChild(rootTargetItem.currentClone);
rootTargetItem.element.getRootNode().appendChild(container);
rootTargetItem.currentClone = container;
}
let children = [...targetRefToTreeNode.get(rootTargetItem.targetRef).children];
targetRefToQuerySelector.set(rootTargetItem.targetRef, '');
while (children.length) {
const childItem = children.shift();
const firstItemWithTargetRef = cloned.get(childItem.targetRef);
const itemId = firstItemWithTargetRef ? firstItemWithTargetRef.id : childItem.id;
childItem.element.setAttribute(childItem.ariaAttribute, itemId);
if (!firstItemWithTargetRef) {
cloned.set(childItem.targetRef, childItem);
const treeNode = targetRefToTreeNode.get(childItem.targetRef);
const selections = [];
let currentParent;
let currentChild = childItem.targetRef;
do {
currentParent = currentChild.getParentAriaRef();
const partialSelector = currentParent.getChildQuerySelector(currentChild);
selections.push(partialSelector);
currentChild = currentParent;
} while (currentParent !== rootTargetItem.targetRef);
const selector = selections.reverse().join(' > ');
const node = rootTargetItem.currentClone.querySelector(selector);
node.id = itemId;
children = children.concat(treeNode.children);
}
}
}
}
function buildQuerySelector(parent, child) {
const path = [];
let el = child;
while (el !== parent) {
let position = 0;
let current = el;
while (current) {
if (current.tagName === el.tagName) position += 1;
current = current.previousElementSibling;
}
path.push(`${el.tagName.toLowerCase()}:nth-of-type(${position})`);
el = el.parentNode;
}
return path.reverse().join(' > ');
}
export default accessibilityDecorator;
//# sourceMappingURL=accessibilityDecorator.js.map