// NOTE: separate `:not()` selectors has broader browser support than the newer // `:not([inert], [inert] *)` (Feb 2023) // CAREFUL: JSDom does not support `:not([inert] *)` as a selector; using it causes // the entire query to fail, resulting in no nodes found, which will break a lot // of things... so we have to rely on JS to identify nodes inside an inert container const candidateSelectors = [ 'input:not([inert])', 'select:not([inert])', 'textarea:not([inert])', 'a[href]:not([inert])', 'button:not([inert])', '[tabindex]:not(slot):not([inert])', 'audio[controls]:not([inert])', 'video[controls]:not([inert])', '[contenteditable]:not([contenteditable="false"]):not([inert])', 'details>summary:first-of-type:not([inert])', 'details:not([inert])', ]; const candidateSelector = /* #__PURE__ */ candidateSelectors.join(','); const NoElement = typeof Element === 'undefined'; const matches = NoElement ? function () {} : Element.prototype.matches || Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector; const getRootNode = !NoElement && Element.prototype.getRootNode ? (element) => element?.getRootNode?.() : (element) => element?.ownerDocument; /** * Determines if a node is inert or in an inert ancestor. * @param {Element} [node] * @param {boolean} [lookUp] If true and `node` is not inert, looks up at ancestors to * see if any of them are inert. If false, only `node` itself is considered. * @returns {boolean} True if inert itself or by way of being in an inert ancestor. * False if `node` is falsy. */ const isInert = function (node, lookUp = true) { // CAREFUL: JSDom does not support inert at all, so we can't use the `HTMLElement.inert` // JS API property; we have to check the attribute, which can either be empty or 'true'; // if it's `null` (not specified) or 'false', it's an active element const inertAtt = node?.getAttribute?.('inert'); const inert = inertAtt === '' || inertAtt === 'true'; // NOTE: this could also be handled with `node.matches('[inert], :is([inert] *)')` // if it weren't for `matches()` not being a function on shadow roots; the following // code works for any kind of node // CAREFUL: JSDom does not appear to support certain selectors like `:not([inert] *)` // so it likely would not support `:is([inert] *)` either... const result = inert || (lookUp && node && isInert(node.parentNode)); // recursive return result; }; /** * Determines if a node's content is editable. * @param {Element} [node] * @returns True if it's content-editable; false if it's not or `node` is falsy. */ const isContentEditable = function (node) { // CAREFUL: JSDom does not support the `HTMLElement.isContentEditable` API so we have // to use the attribute directly to check for this, which can either be empty or 'true'; // if it's `null` (not specified) or 'false', it's a non-editable element const attValue = node?.getAttribute?.('contenteditable'); return attValue === '' || attValue === 'true'; }; /** * @param {Element} el container to check in * @param {boolean} includeContainer add container to check * @param {(node: Element) => boolean} filter filter candidates * @returns {Element[]} */ const getCandidates = function (el, includeContainer, filter) { // even if `includeContainer=false`, we still have to check it for inertness because // if it's inert, all its children are inert if (isInert(el)) { return []; } let candidates = Array.prototype.slice.apply( el.querySelectorAll(candidateSelector) ); if (includeContainer && matches.call(el, candidateSelector)) { candidates.unshift(el); } candidates = candidates.filter(filter); return candidates; }; /** * @callback GetShadowRoot * @param {Element} element to check for shadow root * @returns {ShadowRoot|boolean} ShadowRoot if available or boolean indicating if a shadowRoot is attached but not available. */ /** * @callback ShadowRootFilter * @param {Element} shadowHostNode the element which contains shadow content * @returns {boolean} true if a shadow root could potentially contain valid candidates. */ /** * @typedef {Object} CandidateScope * @property {Element} scopeParent contains inner candidates * @property {Element[]} candidates list of candidates found in the scope parent */ /** * @typedef {Object} IterativeOptions * @property {GetShadowRoot|boolean} getShadowRoot true if shadow support is enabled; falsy if not; * if a function, implies shadow support is enabled and either returns the shadow root of an element * or a boolean stating if it has an undisclosed shadow root * @property {(node: Element) => boolean} filter filter candidates * @property {boolean} flatten if true then result will flatten any CandidateScope into the returned list * @property {ShadowRootFilter} shadowRootFilter filter shadow roots; */ /** * @param {Element[]} elements list of element containers to match candidates from * @param {boolean} includeContainer add container list to check * @param {IterativeOptions} options * @returns {Array.} */ const getCandidatesIteratively = function ( elements, includeContainer, options ) { const candidates = []; const elementsToCheck = Array.from(elements); while (elementsToCheck.length) { const element = elementsToCheck.shift(); if (isInert(element, false)) { // no need to look up since we're drilling down // anything inside this container will also be inert continue; } if (element.tagName === 'SLOT') { // add shadow dom slot scope (slot itself cannot be focusable) const assigned = element.assignedElements(); const content = assigned.length ? assigned : element.children; const nestedCandidates = getCandidatesIteratively(content, true, options); if (options.flatten) { candidates.push(...nestedCandidates); } else { candidates.push({ scopeParent: element, candidates: nestedCandidates, }); } } else { // check candidate element const validCandidate = matches.call(element, candidateSelector); if ( validCandidate && options.filter(element) && (includeContainer || !elements.includes(element)) ) { candidates.push(element); } // iterate over shadow content if possible const shadowRoot = element.shadowRoot || // check for an undisclosed shadow (typeof options.getShadowRoot === 'function' && options.getShadowRoot(element)); // no inert look up because we're already drilling down and checking for inertness // on the way down, so all containers to this root node should have already been // vetted as non-inert const validShadowRoot = !isInert(shadowRoot, false) && (!options.shadowRootFilter || options.shadowRootFilter(element)); if (shadowRoot && validShadowRoot) { // add shadow dom scope IIF a shadow root node was given; otherwise, an undisclosed // shadow exists, so look at light dom children as fallback BUT create a scope for any // child candidates found because they're likely slotted elements (elements that are // children of the web component element (which has the shadow), in the light dom, but // slotted somewhere _inside_ the undisclosed shadow) -- the scope is created below, // _after_ we return from this recursive call const nestedCandidates = getCandidatesIteratively( shadowRoot === true ? element.children : shadowRoot.children, true, options ); if (options.flatten) { candidates.push(...nestedCandidates); } else { candidates.push({ scopeParent: element, candidates: nestedCandidates, }); } } else { // there's not shadow so just dig into the element's (light dom) children // __without__ giving the element special scope treatment elementsToCheck.unshift(...element.children); } } } return candidates; }; /** * @private * Determines if the node has an explicitly specified `tabindex` attribute. * @param {HTMLElement} node * @returns {boolean} True if so; false if not. */ const hasTabIndex = function (node) { return !isNaN(parseInt(node.getAttribute('tabindex'), 10)); }; /** * Determine the tab index of a given node. * @param {HTMLElement} node * @returns {number} Tab order (negative, 0, or positive number). * @throws {Error} If `node` is falsy. */ const getTabIndex = function (node) { if (!node) { throw new Error('No node provided'); } if (node.tabIndex < 0) { // in Chrome,
,