Skip to content

Commit

Permalink
Squashed and rebased JH implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
hoekz-wwt authored and domenic committed May 27, 2023
1 parent a8b03af commit 0605828
Show file tree
Hide file tree
Showing 14 changed files with 443 additions and 28 deletions.
168 changes: 166 additions & 2 deletions lib/jsdom/living/helpers/focusing.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@
const FocusEvent = require("../generated/FocusEvent.js");
const idlUtils = require("../generated/utils.js");
const { isDisabled } = require("./form-controls.js");
const { firstChildWithLocalName } = require("./traversal");
const { createAnEvent } = require("./events");
const { HTML_NS, SVG_NS } = require("./namespaces");
const { isRenderedElement } = require("./svg/render");
const { isShadowInclusiveAncestor } = require("./shadow-dom.js");
const { firstChildWithLocalName, depthFirstIterator } = require("./traversal.js");
const Document = require("../generated/Document.js");
const Node = require("../generated/Node.js");
const { ELEMENT_NODE } = require("../node-type.js");

const focusableFormElements = new Set(["input", "select", "textarea", "button"]);

function isClickFocusable(target) {
return !Number.isNaN(parseInt(target.getAttributeNS(null, "tabindex")));
}

function isUnopenedDialog(target) {
return target.localName === "dialog" && !target.hasAttributeNS(null, "open");
}

// https://html.spec.whatwg.org/multipage/interaction.html#focusable-area, but also some of
// https://html.spec.whatwg.org/multipage/interaction.html#focusing-steps and some of
// https://svgwg.org/svg2-draft/interact.html#TermFocusable
Expand All @@ -26,7 +38,15 @@ exports.isFocusableAreaElement = elImpl => {
return false;
}

if (!Number.isNaN(parseInt(elImpl.getAttributeNS(null, "tabindex")))) {
if (isUnopenedDialog(elImpl)) {
return false;
}

if (elImpl.hasAttributeNS(null, "hidden")) {
return false;
}

if (isClickFocusable(elImpl)) {
return true;
}

Expand Down Expand Up @@ -102,3 +122,147 @@ exports.fireFocusEventWithTargetAdjustment = (name, target, relatedTarget, { bub

target._dispatch(event);
};

function isHTMLAreaElement(target) {
return require("../generated/HTMLAreaElement.js").is(target); // circular dependency, lazy load
}

exports.getFocusableArea = (target, trigger = "other") => {
if (isHTMLAreaElement(target)) {
return firstAreaWithImg(target);
}

const first = firstFocusableArea(target);

if (first) {
return first;
}

if (Document.is(target)) {
return target.viewport;
}

// if (isBrowsingContext(target)) {
// return target.activeDocument;
// }

// if (isBrowsingContextContainer(target) && target.browsingContext) {
// return target.browsingContext.activeDocument;
// }

if (target.shadowRoot && target.shadowRoot.delegatesFocus) {
if (isShadowInclusiveAncestor(target, target._ownerDocument.activeElement)) {
return null;
}

return exports.focusDelegate(target, trigger);
}

return null;
};

function firstAreaWithImg(parent) {
const iterator = depthFirstIterator(parent, isUnopenedDialog);
for (const child of iterator) {
if (exports.isFocusableAreaElement(child) && firstChildWithLocalName(child, "img")) {
return child;
}
}
return null;
}

function firstFocusableArea(parent) {
const iterator = depthFirstIterator(parent, isUnopenedDialog);
for (const child of iterator) {
if (exports.isFocusableAreaElement(child)) {
return child;
}
}
return null;
}

function autoFocusDelegate(target, trigger) {
const iterator = depthFirstIterator(target, isUnopenedDialog);
for (const child of iterator) {
if (child.nodeType === ELEMENT_NODE && child.hasAttributeNS(null, "autofocus")) {
if (child.hasAttributeNS(null, "hidden")) {
continue;
}

let delegate = child;
if (!exports.isFocusableAreaElement(delegate)) {
delegate = exports.getFocusableArea(delegate);
}

if (!delegate) {
continue;
}

if (!isClickFocusable(delegate) && trigger === "click") {
continue;
}

return delegate;
}
}
return null;
}

exports.focusDelegate = (target, trigger = "other") => {
const delegate = autoFocusDelegate(target, trigger);

if (delegate) {
return delegate;
}

if (target.shadowRoot && !target.shadowRoot.delegatesFocus) {
return null;
}


const parent = target.shadowRoot || target;

for (const child of parent.children) {
if (isUnopenedDialog(child)) {
continue;
}

if (exports.isFocusableAreaElement(child)) {
return child;
}

const childDelegate = exports.getFocusableArea(child, trigger);

if (childDelegate) {
return childDelegate;
}
}

return null;
};

exports.focusingSteps = (target, fallback, trigger) => {
if (!exports.isFocusableAreaElement(target)) {
target = exports.getFocusableArea(target, trigger);
}

target ||= fallback;

if (!target) {
return;
}

// if (isBrowsingContextContainer(target) && target.browsingContext) {
// return target.browsingContext.activeDocument;
// }

if (exports.isFocusableAreaElement(target) && (Node.is(target) ? target : target.anchorNode)?.inert) {
return;
}

if (target === target._ownerDocument.activeElement) {
return;
}

target.focus();
};
3 changes: 3 additions & 0 deletions lib/jsdom/living/helpers/stylesheets.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ function fetchStylesheetInternal(elementImpl, urlString, parsedURL) {
let defaultEncoding = document._encoding;
const resourceLoader = document._resourceLoader;

document._scriptBlockingStylesheetCount++;

if (elementImpl.localName === "link" && elementImpl.hasAttributeNS(null, "charset")) {
defaultEncoding = whatwgEncoding.labelToName(elementImpl.getAttributeNS(null, "charset"));
}
Expand All @@ -82,6 +84,7 @@ function fetchStylesheetInternal(elementImpl, urlString, parsedURL) {
exports.removeStylesheet(elementImpl.sheet, elementImpl);
}
exports.createStylesheet(css, elementImpl, parsedURL);
document._scriptBlockingStylesheetCount--;
}

resourceLoader.fetch(urlString, {
Expand Down
17 changes: 17 additions & 0 deletions lib/jsdom/living/helpers/traversal.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,20 @@ exports.firstDescendantWithLocalName = (parent, localName, namespace = HTML_NS)
}
return null;
};

// creates an iterator for all descendants of a parent recursively depth first
// can provide a `subTreeFilter` to ignore a child and all of its descendants
exports.depthFirstIterator = function* (parent, subTreeFilter) {
if (!parent.children) {
return;
}

for (const child of parent.children) {
if (subTreeFilter && subTreeFilter(child)) {
continue;
}

yield child;
yield* exports.depthFirstIterator(child);
}
};
94 changes: 93 additions & 1 deletion lib/jsdom/living/nodes/Document-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const { shadowIncludingInclusiveDescendantsIterator } = require("../helpers/shad
const { enqueueCECallbackReaction } = require("../helpers/custom-elements");
const { createElement, internalCreateElementNSSteps } = require("../helpers/create-element");
const IterableWeakSet = require("../helpers/iterable-weak-set");
const { isFocusableAreaElement, getFocusableArea, focusingSteps } = require("../helpers/focusing");

const DocumentOrShadowRootImpl = require("./DocumentOrShadowRoot-impl").implementation;
const GlobalEventHandlersImpl = require("./GlobalEventHandlers-impl").implementation;
Expand Down Expand Up @@ -136,7 +137,7 @@ class DocumentImpl extends NodeImpl {
ownerDocument: this
});

this._defaultView = privateData.options.defaultView || null;
this._defaultView = privateData.options.defaultView || null; // Browsing Context?
this._global = privateData.options.global;
this._ids = Object.create(null);
this._attached = true;
Expand Down Expand Up @@ -184,6 +185,9 @@ class DocumentImpl extends NodeImpl {
this._requestManager = new RequestManager();
this._currentDocumentReadiness = privateData.options.readyState || "loading";

this._scriptBlockingStylesheetCount = 0;
this._autoFocusCandidates = [];
this._autoFocusProcessed = false;
this._lastFocusedElement = null;

this._resourceLoader = new PerDocumentResourceLoader(this);
Expand All @@ -198,6 +202,13 @@ class DocumentImpl extends NodeImpl {

// Cache of computed element styles
this._styleCache = null;

this._blockedBy = null;
this._topLayer = [];

if (this._fullyActive) {
this._flushAutoFocusCandidates();
}
}

_getTheParent(event) {
Expand Down Expand Up @@ -271,6 +282,10 @@ class DocumentImpl extends NodeImpl {
set readyState(state) {
this._currentDocumentReadiness = state;
fireAnEvent("readystatechange", this);

if (this._currentDocumentReadiness === "complete") {
this._flushAutoFocusCandidates();
}
}

hasFocus() {
Expand Down Expand Up @@ -622,6 +637,8 @@ class DocumentImpl extends NodeImpl {
for (const activeNodeIterator of this._workingNodeIterators) {
activeNodeIterator._preRemovingSteps(oldNode);
}

this._modalUnblock(oldNode);
}

createEvent(type) {
Expand Down Expand Up @@ -910,6 +927,81 @@ class DocumentImpl extends NodeImpl {
copy._origin = this._origin;
return copy;
}

_fullyActive() {
return true; // this._defaultView && this._defaultView.document === this ??
}

_sharesBrowsingContext() {
return true;
}

_flushAutoFocusCandidates() {
if (this._autoFocusProcessed) {
return;
}

if (!this._autoFocusCandidates.length) {
return;
}

if (this.hasFocus()) {
this._autoFocusCandidates = [];
this._autoFocusProcessed = true;
return;
}

while (this._autoFocusCandidates.length) {
const element = this._autoFocusCandidates[0];
const doc = element.ownerDocument;

if (!doc._fullyActive() || !this._sharesBrowsingContext(doc)) {
this._autoFocusCandidates.shift();
continue;
}

if (doc._scriptBlockingStylesheetCount > 0) {
return;
}

this._autoFocusCandidates.shift();

// TODO: check for doc + ancestors having non-null target element

let target = element;

if (!isFocusableAreaElement(target)) {
target = getFocusableArea(target);
}

if (target) {
this._autoFocusCandidates = [];
this._autoFocusProcessed = true;
focusingSteps(target);
}
}
}

_modalBlock(modal) {
this._blockedBy = modal;

if (this._lastFocusedElement && !modal.inert) {
this._lastFocusedElement.blur();
}

if (!this._topLayer.includes(modal)) {
this._topLayer.push(modal);
}
}

_modalUnblock(modal) {
if (this._topLayer.includes(modal)) {
this._topLayer.splice(this._topLayer.indexOf(modal), 1);
if (this._blockedBy === modal) {
this._blockedBy = this._topLayer[this._topLayer.length - 1];
}
}
}
}

eventAccessors.createEventAccessor(DocumentImpl.prototype, "readystatechange");
Expand Down

0 comments on commit 0605828

Please sign in to comment.