diff --git a/cypress/e2e/spec.cy.ts b/cypress/e2e/spec.cy.ts index 933a076..87946c4 100644 --- a/cypress/e2e/spec.cy.ts +++ b/cypress/e2e/spec.cy.ts @@ -440,9 +440,9 @@ describe("focus-shift spec", () => { it( "ignores non-rendered elements", - testFor("./cypress/fixtures/non-rendered-elements.html", { className: "columns" }, [ - { eventType: "keydown", selector: "#first-button", options: keyevent({ key: "ArrowRight" }) }, - { eventType: "keydown", selector: "#last-button", options: keyevent({ key: "ArrowRight" }) } + testFor("./cypress/fixtures/non-rendered-elements.html", { className: "rows" }, [ + { eventType: "keydown", selector: "#first-button", options: keyevent({ key: "ArrowDown" }) }, + { eventType: "keydown", selector: "#last-button", options: keyevent({ key: "ArrowDown" }) } ]) ) }) diff --git a/cypress/fixtures/non-rendered-elements.html b/cypress/fixtures/non-rendered-elements.html index d8a948e..21c8048 100644 --- a/cypress/fixtures/non-rendered-elements.html +++ b/cypress/fixtures/non-rendered-elements.html @@ -10,6 +10,10 @@ .displayNone { display: none; } + .contentVisibilityHidden { + display: inline-block; + content-visibility: hidden; + } .visibilityHidden { visibility: hidden; } @@ -29,14 +33,47 @@ - - - - - - Hidden Element - Hidden Element - + + diff --git a/index.js b/index.js index 098b957..0a05120 100644 --- a/index.js +++ b/index.js @@ -99,7 +99,7 @@ function focusInitial(direction, container) { .filter(hasTabIndex) .filter((elem) => elem.tabIndex > 0) const markedElement = getMinimumBy(tabindexed, (elem) => elem.tabIndex) - if (markedElement != null && isBeingRendered(markedElement)) { + if (markedElement != null && isVisible(markedElement)) { return applyFocus(direction, makeVirtualOrigin(direction), markedElement) } @@ -171,51 +171,76 @@ function isFocusable(element) { // Descends from closed details element if (hasClosedDetailsAncestor(element)) return false - return isBeingRendered(element) + return isVisible(element) } /** - * Decide whether an element is being rendered or not. + * Decide whether an element should count as visible. * - * An element is not being rendered if: - * 1. An element has the style "visibility: hidden | collapse" or "display: none". (Note: these are inherited.) - * 2. An element has the style "opacity: 0". (Somewhat of a white lie, as it will still affect layout.) - * 3. The width or height of an element is explicitly set to 0. - * 4. An element's parent is hidden. + * Our definition of visible is essentially that of the `checkVisibility` web API, but in addition + * we count elements with a zero-size dimension as invisible. * - * @see {@link https://html.spec.whatwg.org/multipage/rendering.html#being-rendered} - * @function isBeingRendered * @param element {Element} * @returns {boolean} */ +function isVisible(element) { + if (!checkVisibility(element)) { + return false + } -function isBeingRendered(element) { - if (element.parentElement) { - const parentStyle = window.getComputedStyle(element.parentElement, null) - if (hasHidingStyleProperty(parentStyle)) return false + if (element instanceof HTMLElement) { + if (element.offsetWidth === 0 || element.offsetHeight === 0) return false } - const elementStyle = window.getComputedStyle(element, null) - if ( - hasHidingStyleProperty(elementStyle) || - elementStyle.getPropertyValue("width") === "0px" || - elementStyle.getPropertyValue("height") === "0px" - ) - return false + return true } /** - * Determine if a style declaration has any properties that make an element hidden. - * @function hasHidingStyleProperty - * @param style {CSSStyleDeclaration} + * Determines whether an element is visible. + * + * This either defers to `Element.checkVisibility()` if available, or does a simple approximation + * of its spec. Returns `true` if the element has a box and is not hidden, fully transparent, or + * skipped due to content-visibility. + * + * @see {@link https://drafts.csswg.org/cssom-view-1/#dom-element-checkvisibility} + * @param element {Element} * @returns {boolean} */ -function hasHidingStyleProperty(style) { - return ( - style.getPropertyValue("display") === "none" || - ["hidden", "collapse"].includes(style.getPropertyValue("visibility")) || - style.getPropertyValue("opacity") === "0" - ) +function checkVisibility(element) { + if (typeof element.checkVisibility === "function") { + const checkAll = { + checkOpacity: true, + checkVisibilityCSS: true, + // With content-visibility auto, though hidden, "the skipped contents must still be available + // as normal to user-agent features such as find-in-page, tab order navigation, etc., and must + // be focusable and selectable as normal." + // https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/content-visibility#auto + contentVisibilityAuto: false, + opacityProperty: true, + visibilityProperty: true + } + return element.checkVisibility(checkAll) + } + + // Approximate checkVisibility + if (!element.isConnected) return false + + /** @type {Element | null} */ + let iter = element + while (iter != null && iter.nodeType === 1) { + const style = getComputedStyle(iter) + + if (style.display === "none") return false + if ("contentVisibility" in style && style.contentVisibility === "hidden") + return false + if (style.opacity === "0") return false + if (style.visibility === "hidden" || style.visibility === "collapse") + return false + + iter = iter.parentElement + } + + return true } /**