UNPKG

@servicenow/ui-renderer-snabbdom

Version:

Snabbdom renderer for UI Framework on Next Experience

253 lines (208 loc) 9.91 kB
/** * 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