Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cypress/e2e/spec.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }) }
])
)
})
53 changes: 45 additions & 8 deletions cypress/fixtures/non-rendered-elements.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
.displayNone {
display: none;
}
.contentVisibilityHidden {
display: inline-block;
content-visibility: hidden;
}
.visibilityHidden {
visibility: hidden;
}
Expand All @@ -29,14 +33,47 @@
</style>
</head>
<body>
<button id="first-button">First Button</button>
<button class="opacityZero">Transparent Button</button>
<button class="displayNone">Hidden Button</button>
<button class="visibilityHidden">Hidden Button</button>
<button class="visibilityCollapse">Hidden Button</button>
<span tabindex="1" class="zeroWidth">Hidden Element</span>
<span tabindex="1" class="zeroHeight">Hidden Element</span>
<button id="last-button">Last Button</button>
<div class="nav-group">
<button id="first-button">First Visible Button</button>

<button class="opacityZero">Transparent Button</button>
<div class="opacityZero">
<div>
<span><button>Nested Transparent Button</button></span>
</div>
</div>

<button class="displayNone">Hidden Button</button>
<div class="displayNone">
<div>
<span><button>Nested Hidden Button</button></span>
</div>
</div>

<!-- Not yet supported by WebKit (16) on macos-latest GitHub runner as of November 2025
<span class="contentVisibilityHidden"><button>Non-Rendered Button</button></span>
-->

<button class="visibilityHidden">Hidden Button</button>
<div class="visibilityHidden">
<div>
<span><button>Nested Hidden Button</button></span>
</div>
</div>

<button class="visibilityCollapse">Nested Collapsed Button</button>
<div class="visibilityCollapse">
<div>
<span><button>Nested Collapsed Button</button></span>
</div>
</div>

<span tabindex="1" class="zeroWidth">Hidden Element</span>
<span tabindex="1" class="zeroHeight">Hidden Element</span>

<button id="last-button">Second Visible Button</button>
</div>

<script src="../../index.js"></script>
</body>
</html>
85 changes: 55 additions & 30 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
}

/**
Expand Down