1108 lines
29 KiB
JavaScript
1108 lines
29 KiB
JavaScript
/**
|
|
* utility.js
|
|
*/
|
|
|
|
/* import */
|
|
import nwsapi from '@asamuzakjp/nwsapi';
|
|
import bidiFactory from 'bidi-js';
|
|
import * as cssTree from 'css-tree';
|
|
import isCustomElementName from 'is-potential-custom-element-name';
|
|
|
|
/* constants */
|
|
import {
|
|
ATRULE,
|
|
COMBO,
|
|
COMPOUND_I,
|
|
DESCEND,
|
|
DOCUMENT_FRAGMENT_NODE,
|
|
DOCUMENT_NODE,
|
|
DOCUMENT_POSITION_CONTAINS,
|
|
DOCUMENT_POSITION_PRECEDING,
|
|
ELEMENT_NODE,
|
|
HAS_COMPOUND,
|
|
INPUT_BUTTON,
|
|
INPUT_EDIT,
|
|
INPUT_LTR,
|
|
INPUT_TEXT,
|
|
KEYS_LOGICAL,
|
|
LOGIC_COMPLEX,
|
|
LOGIC_COMPOUND,
|
|
N_TH,
|
|
PSEUDO_CLASS,
|
|
RULE,
|
|
SCOPE,
|
|
SELECTOR_LIST,
|
|
SIBLING,
|
|
TARGET_ALL,
|
|
TARGET_FIRST,
|
|
TEXT_NODE,
|
|
TYPE_FROM,
|
|
TYPE_TO
|
|
} from './constant.js';
|
|
const KEYS_DIR_AUTO = new Set([...INPUT_BUTTON, ...INPUT_TEXT, 'hidden']);
|
|
const KEYS_DIR_LTR = new Set(INPUT_LTR);
|
|
const KEYS_INPUT_EDIT = new Set(INPUT_EDIT);
|
|
const KEYS_NODE_DIR_EXCLUDE = new Set(['bdi', 'script', 'style', 'textarea']);
|
|
const KEYS_NODE_FOCUSABLE = new Set(['button', 'select', 'textarea']);
|
|
const KEYS_NODE_FOCUSABLE_SVG = new Set([
|
|
'clipPath',
|
|
'defs',
|
|
'desc',
|
|
'linearGradient',
|
|
'marker',
|
|
'mask',
|
|
'metadata',
|
|
'pattern',
|
|
'radialGradient',
|
|
'script',
|
|
'style',
|
|
'symbol',
|
|
'title'
|
|
]);
|
|
const REG_EXCLUDE_BASIC =
|
|
/[|\\]|::|[^\u0021-\u007F\s]|\[\s*[\w$*=^|~-]+(?:(?:"[\w$*=^|~\s'-]+"|'[\w$*=^|~\s"-]+')?(?:\s+[\w$*=^|~-]+)+|"[^"\]]{1,255}|'[^'\]]{1,255})\s*\]|:(?:is|where)\(\s*\)/;
|
|
const REG_COMPLEX = new RegExp(`${COMPOUND_I}${COMBO}${COMPOUND_I}`, 'i');
|
|
const REG_DESCEND = new RegExp(`${COMPOUND_I}${DESCEND}${COMPOUND_I}`, 'i');
|
|
const REG_SIBLING = new RegExp(`${COMPOUND_I}${SIBLING}${COMPOUND_I}`, 'i');
|
|
const REG_LOGIC_COMPLEX = new RegExp(
|
|
`:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPLEX})`
|
|
);
|
|
const REG_LOGIC_COMPOUND = new RegExp(
|
|
`:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPOUND})`
|
|
);
|
|
const REG_LOGIC_HAS_COMPOUND = new RegExp(
|
|
`:(?!${PSEUDO_CLASS}|${N_TH}|${LOGIC_COMPOUND}|${HAS_COMPOUND})`
|
|
);
|
|
const REG_END_WITH_HAS = new RegExp(`:${HAS_COMPOUND}$`);
|
|
const REG_WO_LOGICAL = new RegExp(`:(?!${PSEUDO_CLASS}|${N_TH})`);
|
|
const REG_IS_HTML = /^(?:application\/xhtml\+x|text\/ht)ml$/;
|
|
const REG_IS_XML =
|
|
/^(?:application\/(?:[\w\-.]+\+)?|image\/[\w\-.]+\+|text\/)xml$/;
|
|
|
|
/**
|
|
* Manages state for extracting nested selectors from a CSS AST.
|
|
*/
|
|
class SelectorExtractor {
|
|
constructor() {
|
|
this.selectors = [];
|
|
this.isScoped = false;
|
|
}
|
|
|
|
/**
|
|
* Walker enter function.
|
|
* @param {object} node - The AST node.
|
|
*/
|
|
enter(node) {
|
|
switch (node.type) {
|
|
case ATRULE: {
|
|
if (node.name === 'scope') {
|
|
this.isScoped = true;
|
|
}
|
|
break;
|
|
}
|
|
case SCOPE: {
|
|
const { children, type } = node.root;
|
|
const arr = [];
|
|
if (type === SELECTOR_LIST) {
|
|
for (const child of children) {
|
|
const selector = cssTree.generate(child);
|
|
arr.push(selector);
|
|
}
|
|
this.selectors.push(arr);
|
|
}
|
|
break;
|
|
}
|
|
case RULE: {
|
|
const { children, type } = node.prelude;
|
|
const arr = [];
|
|
if (type === SELECTOR_LIST) {
|
|
let hasAmp = false;
|
|
for (const child of children) {
|
|
const selector = cssTree.generate(child);
|
|
if (this.isScoped && !hasAmp) {
|
|
hasAmp = /\x26/.test(selector);
|
|
}
|
|
arr.push(selector);
|
|
}
|
|
if (this.isScoped) {
|
|
if (hasAmp) {
|
|
this.selectors.push(arr);
|
|
/* FIXME:
|
|
} else {
|
|
this.selectors = arr;
|
|
this.isScoped = false;
|
|
*/
|
|
}
|
|
} else {
|
|
this.selectors.push(arr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Walker leave function.
|
|
* @param {object} node - The AST node.
|
|
*/
|
|
leave(node) {
|
|
if (node.type === ATRULE) {
|
|
if (node.name === 'scope') {
|
|
this.isScoped = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get type of an object.
|
|
* @param {object} o - Object to check.
|
|
* @returns {string} - Type of the object.
|
|
*/
|
|
export const getType = o =>
|
|
Object.prototype.toString.call(o).slice(TYPE_FROM, TYPE_TO);
|
|
|
|
/**
|
|
* Verify array contents.
|
|
* @param {Array} arr - The array.
|
|
* @param {string} type - Expected type, e.g. 'String'.
|
|
* @throws {TypeError} - Throws if array or its items are of unexpected type.
|
|
* @returns {Array} - The verified array.
|
|
*/
|
|
export const verifyArray = (arr, type) => {
|
|
if (!Array.isArray(arr)) {
|
|
throw new TypeError(`Unexpected type ${getType(arr)}`);
|
|
}
|
|
if (typeof type !== 'string') {
|
|
throw new TypeError(`Unexpected type ${getType(type)}`);
|
|
}
|
|
for (const item of arr) {
|
|
if (getType(item) !== type) {
|
|
throw new TypeError(`Unexpected type ${getType(item)}`);
|
|
}
|
|
}
|
|
return arr;
|
|
};
|
|
|
|
/**
|
|
* Generate a DOMException.
|
|
* @param {string} msg - The error message.
|
|
* @param {string} name - The error name.
|
|
* @param {object} globalObject - The global object (e.g., window).
|
|
* @returns {DOMException} The generated DOMException object.
|
|
*/
|
|
export const generateException = (msg, name, globalObject = globalThis) => {
|
|
return new globalObject.DOMException(msg, name);
|
|
};
|
|
|
|
/**
|
|
* Find a nested :has() pseudo-class.
|
|
* @param {object} leaf - The AST leaf to check.
|
|
* @returns {?object} The leaf if it's :has, otherwise null.
|
|
*/
|
|
export const findNestedHas = leaf => {
|
|
return leaf.name === 'has';
|
|
};
|
|
|
|
/**
|
|
* Find a logical pseudo-class that contains a nested :has().
|
|
* @param {object} leaf - The AST leaf to check.
|
|
* @returns {?object} The leaf if it matches, otherwise null.
|
|
*/
|
|
export const findLogicalWithNestedHas = leaf => {
|
|
if (KEYS_LOGICAL.has(leaf.name) && cssTree.find(leaf, findNestedHas)) {
|
|
return leaf;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Filter a list of nodes based on An+B logic
|
|
* @param {Array.<object>} nodes - array of nodes to filter
|
|
* @param {object} anb - An+B options
|
|
* @param {number} anb.a - a
|
|
* @param {number} anb.b - b
|
|
* @param {boolean} [anb.reverse] - reverse order
|
|
* @returns {Array.<object>} - array of matched nodes
|
|
*/
|
|
export const filterNodesByAnB = (nodes, anb) => {
|
|
const { a, b, reverse } = anb;
|
|
const processedNodes = reverse ? [...nodes].reverse() : nodes;
|
|
const l = nodes.length;
|
|
const matched = [];
|
|
if (a === 0) {
|
|
if (b > 0 && b <= l) {
|
|
matched.push(processedNodes[b - 1]);
|
|
}
|
|
return matched;
|
|
}
|
|
let startIndex = b - 1;
|
|
if (a > 0) {
|
|
while (startIndex < 0) {
|
|
startIndex += a;
|
|
}
|
|
for (let i = startIndex; i < l; i += a) {
|
|
matched.push(processedNodes[i]);
|
|
}
|
|
} else if (startIndex >= 0) {
|
|
for (let i = startIndex; i >= 0; i += a) {
|
|
matched.push(processedNodes[i]);
|
|
}
|
|
return matched.reverse();
|
|
}
|
|
return matched;
|
|
};
|
|
|
|
/**
|
|
* Resolve content document, root node, and check if it's in a shadow DOM.
|
|
* @param {object} node - Document, DocumentFragment, or Element node.
|
|
* @returns {Array.<object|boolean>} - [document, root, isInShadow].
|
|
*/
|
|
export const resolveContent = node => {
|
|
if (!node?.nodeType) {
|
|
throw new TypeError(`Unexpected type ${getType(node)}`);
|
|
}
|
|
let document;
|
|
let root;
|
|
let shadow;
|
|
switch (node.nodeType) {
|
|
case DOCUMENT_NODE: {
|
|
document = node;
|
|
root = node;
|
|
break;
|
|
}
|
|
case DOCUMENT_FRAGMENT_NODE: {
|
|
const { host, mode, ownerDocument } = node;
|
|
document = ownerDocument;
|
|
root = node;
|
|
shadow = host && (mode === 'close' || mode === 'open');
|
|
break;
|
|
}
|
|
case ELEMENT_NODE: {
|
|
document = node.ownerDocument;
|
|
let refNode = node;
|
|
while (refNode) {
|
|
const { host, mode, nodeType, parentNode } = refNode;
|
|
if (nodeType === DOCUMENT_FRAGMENT_NODE) {
|
|
shadow = host && (mode === 'close' || mode === 'open');
|
|
break;
|
|
} else if (parentNode) {
|
|
refNode = parentNode;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
root = refNode;
|
|
break;
|
|
}
|
|
default: {
|
|
throw new TypeError(`Unexpected node ${node.nodeName}`);
|
|
}
|
|
}
|
|
return [document, root, !!shadow];
|
|
};
|
|
|
|
/**
|
|
* Traverse node tree with a TreeWalker.
|
|
* @param {object} node - The target node.
|
|
* @param {object} walker - The TreeWalker instance.
|
|
* @param {boolean} [force] - Traverse only to the next node.
|
|
* @returns {?object} - The current node if found, otherwise null.
|
|
*/
|
|
export const traverseNode = (node, walker, force = false) => {
|
|
if (!node?.nodeType) {
|
|
throw new TypeError(`Unexpected type ${getType(node)}`);
|
|
}
|
|
if (!walker) {
|
|
return null;
|
|
}
|
|
let refNode = walker.currentNode;
|
|
if (refNode === node) {
|
|
return refNode;
|
|
} else if (force || refNode.contains(node)) {
|
|
refNode = walker.nextNode();
|
|
while (refNode) {
|
|
if (refNode === node) {
|
|
break;
|
|
}
|
|
refNode = walker.nextNode();
|
|
}
|
|
return refNode;
|
|
} else {
|
|
if (refNode !== walker.root) {
|
|
let bool;
|
|
while (refNode) {
|
|
if (refNode === node) {
|
|
bool = true;
|
|
break;
|
|
} else if (refNode === walker.root || refNode.contains(node)) {
|
|
break;
|
|
}
|
|
refNode = walker.parentNode();
|
|
}
|
|
if (bool) {
|
|
return refNode;
|
|
}
|
|
}
|
|
if (node.nodeType === ELEMENT_NODE) {
|
|
let bool;
|
|
while (refNode) {
|
|
if (refNode === node) {
|
|
bool = true;
|
|
break;
|
|
}
|
|
refNode = walker.nextNode();
|
|
}
|
|
if (bool) {
|
|
return refNode;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Check if a node is a custom element.
|
|
* @param {object} node - The Element node.
|
|
* @param {object} [opt] - Options.
|
|
* @returns {boolean} - True if it's a custom element.
|
|
*/
|
|
export const isCustomElement = (node, opt = {}) => {
|
|
if (!node?.nodeType) {
|
|
throw new TypeError(`Unexpected type ${getType(node)}`);
|
|
}
|
|
if (node.nodeType !== ELEMENT_NODE) {
|
|
return false;
|
|
}
|
|
const { localName, ownerDocument } = node;
|
|
const { formAssociated } = opt;
|
|
const window = ownerDocument.defaultView;
|
|
let elmConstructor;
|
|
const attr = node.getAttribute('is');
|
|
if (attr) {
|
|
elmConstructor =
|
|
isCustomElementName(attr) && window.customElements.get(attr);
|
|
} else {
|
|
elmConstructor =
|
|
isCustomElementName(localName) && window.customElements.get(localName);
|
|
}
|
|
if (elmConstructor) {
|
|
if (formAssociated) {
|
|
return !!elmConstructor.formAssociated;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Get slotted text content.
|
|
* @param {object} node - The Element node (likely a <slot>).
|
|
* @returns {?string} - The text content.
|
|
*/
|
|
export const getSlottedTextContent = node => {
|
|
if (!node?.nodeType) {
|
|
throw new TypeError(`Unexpected type ${getType(node)}`);
|
|
}
|
|
if (typeof node.assignedNodes !== 'function') {
|
|
return null;
|
|
}
|
|
const nodes = node.assignedNodes();
|
|
if (nodes.length) {
|
|
let text = '';
|
|
const l = nodes.length;
|
|
for (let i = 0; i < l; i++) {
|
|
const item = nodes[i];
|
|
text = item.textContent.trim();
|
|
if (text) {
|
|
break;
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
return node.textContent.trim();
|
|
};
|
|
|
|
/**
|
|
* Get directionality of a node.
|
|
* @see https://html.spec.whatwg.org/multipage/dom.html#the-dir-attribute
|
|
* @param {object} node - The Element node.
|
|
* @returns {?string} - 'ltr' or 'rtl'.
|
|
*/
|
|
export const getDirectionality = node => {
|
|
if (!node?.nodeType) {
|
|
throw new TypeError(`Unexpected type ${getType(node)}`);
|
|
}
|
|
if (node.nodeType !== ELEMENT_NODE) {
|
|
return null;
|
|
}
|
|
const { dir: dirAttr, localName, parentNode } = node;
|
|
const { getEmbeddingLevels } = bidiFactory();
|
|
if (dirAttr === 'ltr' || dirAttr === 'rtl') {
|
|
return dirAttr;
|
|
} else if (dirAttr === 'auto') {
|
|
let text = '';
|
|
switch (localName) {
|
|
case 'input': {
|
|
if (!node.type || KEYS_DIR_AUTO.has(node.type)) {
|
|
text = node.value;
|
|
} else if (KEYS_DIR_LTR.has(node.type)) {
|
|
return 'ltr';
|
|
}
|
|
break;
|
|
}
|
|
case 'slot': {
|
|
text = getSlottedTextContent(node);
|
|
break;
|
|
}
|
|
case 'textarea': {
|
|
text = node.value;
|
|
break;
|
|
}
|
|
default: {
|
|
const items = [].slice.call(node.childNodes);
|
|
for (const item of items) {
|
|
const {
|
|
dir: itemDir,
|
|
localName: itemLocalName,
|
|
nodeType: itemNodeType,
|
|
textContent: itemTextContent
|
|
} = item;
|
|
if (itemNodeType === TEXT_NODE) {
|
|
text = itemTextContent.trim();
|
|
} else if (
|
|
itemNodeType === ELEMENT_NODE &&
|
|
!KEYS_NODE_DIR_EXCLUDE.has(itemLocalName) &&
|
|
(!itemDir || (itemDir !== 'ltr' && itemDir !== 'rtl'))
|
|
) {
|
|
if (itemLocalName === 'slot') {
|
|
text = getSlottedTextContent(item);
|
|
} else {
|
|
text = itemTextContent.trim();
|
|
}
|
|
}
|
|
if (text) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (text) {
|
|
const {
|
|
paragraphs: [{ level }]
|
|
} = getEmbeddingLevels(text);
|
|
if (level % 2 === 1) {
|
|
return 'rtl';
|
|
}
|
|
} else if (parentNode) {
|
|
const { nodeType: parentNodeType } = parentNode;
|
|
if (parentNodeType === ELEMENT_NODE) {
|
|
return getDirectionality(parentNode);
|
|
}
|
|
}
|
|
} else if (localName === 'input' && node.type === 'tel') {
|
|
return 'ltr';
|
|
} else if (localName === 'bdi') {
|
|
const text = node.textContent.trim();
|
|
if (text) {
|
|
const {
|
|
paragraphs: [{ level }]
|
|
} = getEmbeddingLevels(text);
|
|
if (level % 2 === 1) {
|
|
return 'rtl';
|
|
}
|
|
}
|
|
} else if (parentNode) {
|
|
if (localName === 'slot') {
|
|
const text = getSlottedTextContent(node);
|
|
if (text) {
|
|
const {
|
|
paragraphs: [{ level }]
|
|
} = getEmbeddingLevels(text);
|
|
if (level % 2 === 1) {
|
|
return 'rtl';
|
|
}
|
|
return 'ltr';
|
|
}
|
|
}
|
|
const { nodeType: parentNodeType } = parentNode;
|
|
if (parentNodeType === ELEMENT_NODE) {
|
|
return getDirectionality(parentNode);
|
|
}
|
|
}
|
|
return 'ltr';
|
|
};
|
|
|
|
/**
|
|
* Traverses up the DOM tree to find the language attribute for a node.
|
|
* It checks for 'lang' in HTML and 'xml:lang' in XML contexts.
|
|
* @param {object} node - The starting element node.
|
|
* @returns {string|null} The language attribute value, or null if not found.
|
|
*/
|
|
export const getLanguageAttribute = node => {
|
|
if (!node?.nodeType) {
|
|
throw new TypeError(`Unexpected type ${getType(node)}`);
|
|
}
|
|
if (node.nodeType !== ELEMENT_NODE) {
|
|
return null;
|
|
}
|
|
const { contentType } = node.ownerDocument;
|
|
const isHtml = REG_IS_HTML.test(contentType);
|
|
const isXml = REG_IS_XML.test(contentType);
|
|
let isShadow = false;
|
|
// Traverse up from the current node to the root.
|
|
let current = node;
|
|
while (current) {
|
|
// Check if the current node is an element.
|
|
switch (current.nodeType) {
|
|
case ELEMENT_NODE: {
|
|
// Check for and return the language attribute if present.
|
|
if (isHtml && current.hasAttribute('lang')) {
|
|
return current.getAttribute('lang');
|
|
} else if (isXml && current.hasAttribute('xml:lang')) {
|
|
return current.getAttribute('xml:lang');
|
|
}
|
|
break;
|
|
}
|
|
case DOCUMENT_FRAGMENT_NODE: {
|
|
// Continue traversal if the current node is a shadow root.
|
|
if (current.host) {
|
|
isShadow = true;
|
|
}
|
|
break;
|
|
}
|
|
case DOCUMENT_NODE:
|
|
default: {
|
|
// Stop if we reach the root document node.
|
|
return null;
|
|
}
|
|
}
|
|
if (isShadow) {
|
|
current = current.host;
|
|
isShadow = false;
|
|
} else if (current.parentNode) {
|
|
current = current.parentNode;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
// No language attribute was found in the hierarchy.
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Check if content is editable.
|
|
* NOTE: Not implemented in jsdom https://github.com/jsdom/jsdom/issues/1670
|
|
* @param {object} node - The Element node.
|
|
* @returns {boolean} - True if content is editable.
|
|
*/
|
|
export const isContentEditable = node => {
|
|
if (!node?.nodeType) {
|
|
throw new TypeError(`Unexpected type ${getType(node)}`);
|
|
}
|
|
if (node.nodeType !== ELEMENT_NODE) {
|
|
return false;
|
|
}
|
|
if (typeof node.isContentEditable === 'boolean') {
|
|
return node.isContentEditable;
|
|
} else if (node.ownerDocument.designMode === 'on') {
|
|
return true;
|
|
} else {
|
|
let attr;
|
|
if (node.hasAttribute('contenteditable')) {
|
|
attr = node.getAttribute('contenteditable');
|
|
} else {
|
|
attr = 'inherit';
|
|
}
|
|
switch (attr) {
|
|
case '':
|
|
case 'true': {
|
|
return true;
|
|
}
|
|
case 'plaintext-only': {
|
|
// FIXME:
|
|
// @see https://github.com/w3c/editing/issues/470
|
|
// @see https://github.com/whatwg/html/issues/10651
|
|
return true;
|
|
}
|
|
case 'false': {
|
|
return false;
|
|
}
|
|
default: {
|
|
if (node?.parentNode?.nodeType === ELEMENT_NODE) {
|
|
return isContentEditable(node.parentNode);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check if a node is visible.
|
|
* @param {object} node - The Element node.
|
|
* @returns {boolean} - True if the node is visible.
|
|
*/
|
|
export const isVisible = node => {
|
|
if (node?.nodeType !== ELEMENT_NODE) {
|
|
return false;
|
|
}
|
|
const window = node.ownerDocument.defaultView;
|
|
const { display, visibility } = window.getComputedStyle(node);
|
|
if (display !== 'none' && visibility === 'visible') {
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Check if focus is visible on the node.
|
|
* @param {object} node - The Element node.
|
|
* @returns {boolean} - True if focus is visible.
|
|
*/
|
|
export const isFocusVisible = node => {
|
|
if (node?.nodeType !== ELEMENT_NODE) {
|
|
return false;
|
|
}
|
|
const { localName, type } = node;
|
|
switch (localName) {
|
|
case 'input': {
|
|
if (!type || KEYS_INPUT_EDIT.has(type)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
case 'textarea': {
|
|
return true;
|
|
}
|
|
default: {
|
|
return isContentEditable(node);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check if an area is focusable.
|
|
* @param {object} node - The Element node.
|
|
* @returns {boolean} - True if the area is focusable.
|
|
*/
|
|
export const isFocusableArea = node => {
|
|
if (node?.nodeType !== ELEMENT_NODE) {
|
|
return false;
|
|
}
|
|
if (!node.isConnected) {
|
|
return false;
|
|
}
|
|
const window = node.ownerDocument.defaultView;
|
|
if (node instanceof window.HTMLElement) {
|
|
if (Number.isInteger(parseInt(node.getAttribute('tabindex')))) {
|
|
return true;
|
|
}
|
|
if (isContentEditable(node)) {
|
|
return true;
|
|
}
|
|
const { localName, parentNode } = node;
|
|
switch (localName) {
|
|
case 'a': {
|
|
if (node.href || node.hasAttribute('href')) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
case 'iframe': {
|
|
return true;
|
|
}
|
|
case 'input': {
|
|
if (
|
|
node.disabled ||
|
|
node.hasAttribute('disabled') ||
|
|
node.hidden ||
|
|
node.hasAttribute('hidden')
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
case 'summary': {
|
|
if (parentNode.localName === 'details') {
|
|
let child = parentNode.firstElementChild;
|
|
let bool = false;
|
|
while (child) {
|
|
if (child.localName === 'summary') {
|
|
bool = child === node;
|
|
break;
|
|
}
|
|
child = child.nextElementSibling;
|
|
}
|
|
return bool;
|
|
}
|
|
return false;
|
|
}
|
|
default: {
|
|
if (
|
|
KEYS_NODE_FOCUSABLE.has(localName) &&
|
|
!(node.disabled || node.hasAttribute('disabled'))
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
} else if (node instanceof window.SVGElement) {
|
|
if (Number.isInteger(parseInt(node.getAttributeNS(null, 'tabindex')))) {
|
|
const ns = 'http://www.w3.org/2000/svg';
|
|
let bool;
|
|
let refNode = node;
|
|
while (refNode.namespaceURI === ns) {
|
|
bool = KEYS_NODE_FOCUSABLE_SVG.has(refNode.localName);
|
|
if (bool) {
|
|
break;
|
|
}
|
|
if (refNode?.parentNode?.namespaceURI === ns) {
|
|
refNode = refNode.parentNode;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
if (bool) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
if (
|
|
node.localName === 'a' &&
|
|
(node.href || node.hasAttributeNS(null, 'href'))
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Check if a node is focusable.
|
|
* NOTE: Not applied, needs fix in jsdom itself.
|
|
* @see https://github.com/whatwg/html/pull/8392
|
|
* @see https://phabricator.services.mozilla.com/D156219
|
|
* @see https://github.com/jsdom/jsdom/issues/3029
|
|
* @see https://github.com/jsdom/jsdom/issues/3464
|
|
* @param {object} node - The Element node.
|
|
* @returns {boolean} - True if the node is focusable.
|
|
*/
|
|
export const isFocusable = node => {
|
|
if (node?.nodeType !== ELEMENT_NODE) {
|
|
return false;
|
|
}
|
|
const window = node.ownerDocument.defaultView;
|
|
let refNode = node;
|
|
let res = true;
|
|
while (refNode) {
|
|
if (refNode.disabled || refNode.hasAttribute('disabled')) {
|
|
res = false;
|
|
break;
|
|
}
|
|
if (refNode.hidden || refNode.hasAttribute('hidden')) {
|
|
res = false;
|
|
}
|
|
const { contentVisibility, display, visibility } =
|
|
window.getComputedStyle(refNode);
|
|
if (
|
|
display === 'none' ||
|
|
visibility !== 'visible' ||
|
|
(contentVisibility === 'hidden' && refNode !== node)
|
|
) {
|
|
res = false;
|
|
} else {
|
|
res = true;
|
|
}
|
|
if (res && refNode?.parentNode?.nodeType === ELEMENT_NODE) {
|
|
refNode = refNode.parentNode;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return res;
|
|
};
|
|
|
|
/**
|
|
* Get namespace URI.
|
|
* @param {string} ns - The namespace prefix.
|
|
* @param {object} node - The Element node.
|
|
* @returns {?string} - The namespace URI.
|
|
*/
|
|
export const getNamespaceURI = (ns, node) => {
|
|
if (typeof ns !== 'string') {
|
|
throw new TypeError(`Unexpected type ${getType(ns)}`);
|
|
} else if (!node?.nodeType) {
|
|
throw new TypeError(`Unexpected type ${getType(node)}`);
|
|
}
|
|
if (!ns || node.nodeType !== ELEMENT_NODE) {
|
|
return null;
|
|
}
|
|
const { attributes } = node;
|
|
let res;
|
|
for (const attr of attributes) {
|
|
const { name, namespaceURI, prefix, value } = attr;
|
|
if (name === `xmlns:${ns}`) {
|
|
res = value;
|
|
} else if (prefix === ns) {
|
|
res = namespaceURI;
|
|
}
|
|
if (res) {
|
|
break;
|
|
}
|
|
}
|
|
return res ?? null;
|
|
};
|
|
|
|
/**
|
|
* Check if a namespace is declared.
|
|
* @param {string} ns - The namespace.
|
|
* @param {object} node - The Element node.
|
|
* @returns {boolean} - True if the namespace is declared.
|
|
*/
|
|
export const isNamespaceDeclared = (ns = '', node = {}) => {
|
|
if (!ns || typeof ns !== 'string' || node?.nodeType !== ELEMENT_NODE) {
|
|
return false;
|
|
}
|
|
if (node.lookupNamespaceURI(ns)) {
|
|
return true;
|
|
}
|
|
const root = node.ownerDocument.documentElement;
|
|
let parent = node;
|
|
let res;
|
|
while (parent) {
|
|
res = getNamespaceURI(ns, parent);
|
|
if (res || parent === root) {
|
|
break;
|
|
}
|
|
parent = parent.parentNode;
|
|
}
|
|
return !!res;
|
|
};
|
|
|
|
/**
|
|
* Check if nodeA precedes and/or contains nodeB.
|
|
* @param {object} nodeA - The first Element node.
|
|
* @param {object} nodeB - The second Element node.
|
|
* @returns {boolean} - True if nodeA precedes nodeB.
|
|
*/
|
|
export const isPreceding = (nodeA, nodeB) => {
|
|
if (!nodeA?.nodeType) {
|
|
throw new TypeError(`Unexpected type ${getType(nodeA)}`);
|
|
} else if (!nodeB?.nodeType) {
|
|
throw new TypeError(`Unexpected type ${getType(nodeB)}`);
|
|
}
|
|
if (nodeA.nodeType !== ELEMENT_NODE || nodeB.nodeType !== ELEMENT_NODE) {
|
|
return false;
|
|
}
|
|
const posBit = nodeB.compareDocumentPosition(nodeA);
|
|
const res =
|
|
posBit & DOCUMENT_POSITION_PRECEDING || posBit & DOCUMENT_POSITION_CONTAINS;
|
|
return !!res;
|
|
};
|
|
|
|
/**
|
|
* Comparison function for sorting nodes based on document position.
|
|
* @param {object} a - The first node.
|
|
* @param {object} b - The second node.
|
|
* @returns {number} - Sort order.
|
|
*/
|
|
export const compareNodes = (a, b) => {
|
|
if (isPreceding(b, a)) {
|
|
return 1;
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
/**
|
|
* Sort a collection of nodes.
|
|
* @param {Array.<object>|Set.<object>} nodes - Collection of nodes.
|
|
* @returns {Array.<object>} - Collection of sorted nodes.
|
|
*/
|
|
export const sortNodes = (nodes = []) => {
|
|
const arr = [...nodes];
|
|
if (arr.length > 1) {
|
|
arr.sort(compareNodes);
|
|
}
|
|
return arr;
|
|
};
|
|
|
|
/**
|
|
* Concat an array of nested selectors into an equivalent single selector.
|
|
* @param {Array.<Array.<string>>} selectors - [parents, children, ...].
|
|
* @returns {string} - The concatenated selector.
|
|
*/
|
|
export const concatNestedSelectors = selectors => {
|
|
if (!Array.isArray(selectors)) {
|
|
throw new TypeError(`Unexpected type ${getType(selectors)}`);
|
|
}
|
|
let selector = '';
|
|
if (selectors.length) {
|
|
const revSelectors = selectors.toReversed();
|
|
let child = verifyArray(revSelectors.shift(), 'String');
|
|
if (child.length === 1) {
|
|
[child] = child;
|
|
}
|
|
while (revSelectors.length) {
|
|
const parentArr = verifyArray(revSelectors.shift(), 'String');
|
|
if (!parentArr.length) {
|
|
continue;
|
|
}
|
|
let parent;
|
|
if (parentArr.length === 1) {
|
|
[parent] = parentArr;
|
|
if (!/^[>~+]/.test(parent) && /[\s>~+]/.test(parent)) {
|
|
parent = `:is(${parent})`;
|
|
}
|
|
} else {
|
|
parent = `:is(${parentArr.join(', ')})`;
|
|
}
|
|
if (selector.includes('\x26')) {
|
|
selector = selector.replace(/\x26/g, parent);
|
|
}
|
|
if (Array.isArray(child)) {
|
|
const items = [];
|
|
for (let item of child) {
|
|
if (item.includes('\x26')) {
|
|
if (/^[>~+]/.test(item)) {
|
|
item = `${parent} ${item.replace(/\x26/g, parent)} ${selector}`;
|
|
} else {
|
|
item = `${item.replace(/\x26/g, parent)} ${selector}`;
|
|
}
|
|
} else {
|
|
item = `${parent} ${item} ${selector}`;
|
|
}
|
|
items.push(item.trim());
|
|
}
|
|
selector = items.join(', ');
|
|
} else if (revSelectors.length) {
|
|
selector = `${child} ${selector}`;
|
|
} else {
|
|
if (child.includes('\x26')) {
|
|
if (/^[>~+]/.test(child)) {
|
|
selector = `${parent} ${child.replace(/\x26/g, parent)} ${selector}`;
|
|
} else {
|
|
selector = `${child.replace(/\x26/g, parent)} ${selector}`;
|
|
}
|
|
} else {
|
|
selector = `${parent} ${child} ${selector}`;
|
|
}
|
|
}
|
|
selector = selector.trim();
|
|
if (revSelectors.length) {
|
|
child = parentArr.length > 1 ? parentArr : parent;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
selector = selector.replace(/\x26/g, ':scope').trim();
|
|
}
|
|
return selector;
|
|
};
|
|
|
|
/**
|
|
* Extract nested selectors from CSSRule.cssText.
|
|
* @param {string} css - CSSRule.cssText.
|
|
* @returns {Array.<Array.<string>>} - Array of nested selectors.
|
|
*/
|
|
export const extractNestedSelectors = css => {
|
|
const ast = cssTree.parse(css, {
|
|
context: 'rule'
|
|
});
|
|
const extractor = new SelectorExtractor();
|
|
cssTree.walk(ast, {
|
|
enter: extractor.enter.bind(extractor),
|
|
leave: extractor.leave.bind(extractor)
|
|
});
|
|
return extractor.selectors;
|
|
};
|
|
|
|
/**
|
|
* Initialize nwsapi.
|
|
* @param {object} window - The Window object.
|
|
* @param {object} document - The Document object.
|
|
* @returns {object} - The nwsapi instance.
|
|
*/
|
|
export const initNwsapi = (window, document) => {
|
|
if (!window?.DOMException) {
|
|
throw new TypeError(`Unexpected global object ${getType(window)}`);
|
|
}
|
|
if (document?.nodeType !== DOCUMENT_NODE) {
|
|
document = window.document;
|
|
}
|
|
const nw = nwsapi({
|
|
document,
|
|
DOMException: window.DOMException
|
|
});
|
|
nw.configure({
|
|
LOGERRORS: false
|
|
});
|
|
return nw;
|
|
};
|
|
|
|
/**
|
|
* Filter a selector for use with nwsapi.
|
|
* @param {string} selector - The selector string.
|
|
* @param {string} target - The target type.
|
|
* @returns {boolean} - True if the selector is valid for nwsapi.
|
|
*/
|
|
export const filterSelector = (selector, target) => {
|
|
const isQuerySelectorType = target === TARGET_FIRST || target === TARGET_ALL;
|
|
if (
|
|
!selector ||
|
|
typeof selector !== 'string' ||
|
|
/null|undefined/.test(selector)
|
|
) {
|
|
return false;
|
|
}
|
|
// Exclude missing close square bracket.
|
|
if (selector.includes('[')) {
|
|
const index = selector.lastIndexOf('[');
|
|
const sel = selector.substring(index);
|
|
if (sel.indexOf(']') < 0) {
|
|
return false;
|
|
}
|
|
}
|
|
// Exclude various complex or unsupported selectors.
|
|
// - selectors containing '/'
|
|
// - namespaced selectors
|
|
// - escaped selectors
|
|
// - pseudo-element selectors
|
|
// - selectors containing non-ASCII
|
|
// - selectors containing control character other than whitespace
|
|
// - attribute selectors with case flag, e.g. [attr i]
|
|
// - attribute selectors with unclosed quotes
|
|
// - empty :is() or :where()
|
|
if (selector.includes('/') || REG_EXCLUDE_BASIC.test(selector)) {
|
|
return false;
|
|
}
|
|
// Include pseudo-classes that are known to work correctly.
|
|
if (selector.includes(':')) {
|
|
let complex = false;
|
|
if (target !== isQuerySelectorType) {
|
|
complex = REG_COMPLEX.test(selector);
|
|
}
|
|
if (
|
|
isQuerySelectorType &&
|
|
REG_DESCEND.test(selector) &&
|
|
!REG_SIBLING.test(selector)
|
|
) {
|
|
return false;
|
|
} else if (!isQuerySelectorType && /:has\(/.test(selector)) {
|
|
if (!complex || REG_LOGIC_HAS_COMPOUND.test(selector)) {
|
|
return false;
|
|
}
|
|
return REG_END_WITH_HAS.test(selector);
|
|
} else if (/:(?:is|not)\(/.test(selector)) {
|
|
if (complex) {
|
|
return !REG_LOGIC_COMPLEX.test(selector);
|
|
} else {
|
|
return !REG_LOGIC_COMPOUND.test(selector);
|
|
}
|
|
} else {
|
|
return !REG_WO_LOGICAL.test(selector);
|
|
}
|
|
}
|
|
return true;
|
|
};
|