From c917bde1a158db21aa2d5749d5714bedc9e3475e Mon Sep 17 00:00:00 2001 From: konnorrogers Date: Wed, 10 Jan 2024 15:45:26 -0500 Subject: [PATCH] fix tabbing into overflowing elements --- CHANGELOG.md | 5 ++ exports/focus-hunter.js | 57 ++++++++++----------- exports/tabbable.js | 90 ++++++++++++++++++++++++++------- index.html | 109 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a490c3..d77c5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.1.1 + +- Fixed tabbing into overflowing elements, and reduced the chances of the focus getting "stuck" +on unrecognized / unfocusable elements. + ## 1.1.0 - Improved the performance of tabbable checking by using `checkVisibility()` diff --git a/exports/focus-hunter.js b/exports/focus-hunter.js index 2debe3d..a68eba7 100644 --- a/exports/focus-hunter.js +++ b/exports/focus-hunter.js @@ -1,4 +1,5 @@ // @ts-check +import { activeElements } from './active-elements.js'; import { deepestActiveElement } from './active-elements.js'; import { getTabbableElements } from './tabbable.js'; @@ -230,47 +231,43 @@ export class Trap { const tabbableElements = [...getTabbableElements(this.rootElement)]; - const start = tabbableElements[0] - let currentFocusIndex = tabbableElements.findIndex((el) => el === currentFocus) - if (currentFocusIndex === -1) { - this.currentFocus = (/** @type {HTMLElement} */ (start)); - - event.preventDefault() - this.currentFocus?.focus?.({ preventScroll: this.preventScroll }); - return; - } - const addition = this.tabDirection === 'forward' ? 1 : -1; - if (currentFocusIndex + addition >= tabbableElements.length) { - currentFocusIndex = 0; - } else if (currentFocusIndex + addition < 0) { - currentFocusIndex = tabbableElements.length - 1; - } else { - currentFocusIndex += addition; - } + while(true) { + if (currentFocusIndex + addition >= tabbableElements.length) { + currentFocusIndex = 0; + } else if (currentFocusIndex + addition < 0) { + currentFocusIndex = tabbableElements.length - 1; + } else { + currentFocusIndex += addition; + } - this.previousFocus = this.currentFocus - const nextFocus = /** @type {HTMLElement} */ (tabbableElements[currentFocusIndex]) + this.previousFocus = this.currentFocus + const nextFocus = /** @type {HTMLElement} */ (tabbableElements[currentFocusIndex]) + // This is a special case. We need to make sure we're not calling .focus() if we're already focused on an element + // that possibly has "controls" + if (this.tabDirection === "backward") { + if (this.previousFocus && this.possiblyHasTabbableChildren(/** @type {HTMLElement} */ (this.previousFocus))) { + return + } - // This is a special case. We need to make sure we're not calling .focus() if we're already focused on an element - // that possibly has "controls" - if (this.tabDirection === "backward") { - if (this.previousFocus && this.possiblyHasTabbableChildren(/** @type {HTMLElement} */ (this.previousFocus))) { - return + if (nextFocus && this.possiblyHasTabbableChildren(nextFocus)) { + return + } } - if (nextFocus && this.possiblyHasTabbableChildren(nextFocus)) { - return + event.preventDefault() + this.currentFocus = nextFocus; + this.currentFocus?.focus({ preventScroll: this.preventScroll }); + + // @ts-expect-error + if ([...activeElements()].includes(this.currentFocus)) { + break } } - - event.preventDefault() - this.currentFocus = nextFocus; - this.currentFocus?.focus({ preventScroll: this.preventScroll }); } diff --git a/exports/tabbable.js b/exports/tabbable.js index 678ab86..3928f44 100644 --- a/exports/tabbable.js +++ b/exports/tabbable.js @@ -7,16 +7,11 @@ const computedStyleMap = /** @type {WeakMap} */ (n /** * @param {Element} el + * @returns {CSSStyleDeclaration} */ -function isVisible(el) { - // This is the fastest check, but isn't supported in Safari. - if (typeof el.checkVisibility === 'function') { - return el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); - } - - // Fallback "polyfill" for "checkVisibility" +function getCachedComputedStyle(el) { /** - * @type {CSSStyleDeclaration | undefined} + * @type {undefined | CSSStyleDeclaration} */ let computedStyle = computedStyleMap.get(el); @@ -25,9 +20,60 @@ function isVisible(el) { computedStyleMap.set(el, computedStyle); } + return /** @type {CSSStyleDeclaration} */ (computedStyle); +} + +/** + * @param {Element} el + */ +function isVisible(el) { + // This is the fastest check, but isn't supported in Safari. + if (typeof el.checkVisibility === 'function') { + return el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); + } + + // Fallback "polyfill" for "checkVisibility" + const computedStyle = getCachedComputedStyle(el); + return computedStyle.visibility !== 'hidden' && computedStyle.display !== 'none'; } +/** + * While this behavior isn't standard in Safari / Chrome yet, I think it's the most reasonable + * way of handling tabbable overflow areas. Browser sniffing seems gross, and it's the most + * accessible way of handling overflow areas. [Konnor] + * @param {Element} el + * @return {boolean} + */ +function isOverflowingAndTabbable(el) { + const computedStyle = getCachedComputedStyle(el); + + const { overflowY, overflowX } = computedStyle; + + if (overflowY === 'scroll' || overflowX === 'scroll') { + return true; + } + + if (overflowY !== 'auto' || overflowX !== 'auto') { + return false; + } + + // Always overflow === "auto" by this point + const isOverflowingY = el.scrollHeight > el.clientHeight; + + if (isOverflowingY && overflowY === 'auto') { + return true; + } + + const isOverflowingX = el.scrollWidth > el.clientWidth; + + if (isOverflowingX && overflowX === 'auto') { + return true; + } + + return false; +} + /** * Determines if the specified element is tabbable using heuristics inspired by https://github.com/focus-trap/tabbable @@ -37,17 +83,9 @@ function isVisible(el) { export function isTabbable(el) { const tag = el.tagName.toLowerCase(); - // Elements that are hidden have no offsetParent and are not tabbable - // offsetParent() is added because otherwise it misses elements in Safari - if ( - !isVisible(/** @type {HTMLElement} */ (el)) - ) - { - return false; - } + const tabindex = Number(el.getAttribute('tabindex')); + const hasTabindex = el.hasAttribute('tabindex'); - const tabindex = Number(el.getAttribute('tabindex')) - const hasTabindex = el.hasAttribute("tabindex") // elements with a tabindex attribute that is either NaN or <= -1 are not tabbable if (hasTabindex && (isNaN(tabindex) || tabindex <= -1)) { @@ -68,6 +106,13 @@ export function isTabbable(el) { return false; } + // Elements that are hidden have no offsetParent and are not tabbable + // offsetParent() is added because otherwise it misses elements in Safari + if (!isVisible(/** @type {HTMLElement} */ (el))) + { + return false; + } + // Anchor tags with no hrefs arent focusable. // This is focusable: Stuff // This is not focusable: Stuff @@ -89,7 +134,14 @@ export function isTabbable(el) { } // At this point, the following elements are considered tabbable - return ['button', 'input', 'select', 'textarea', 'a', 'audio', 'video', 'summary', 'iframe', 'object', 'embed'].includes(tag); + const isNativelyTabbable = ['button', 'input', 'select', 'textarea', 'a', 'audio', 'video', 'summary', 'iframe', 'object', 'embed'].includes(tag); + + if (isNativelyTabbable) { + return true; + } + + // We save the overflow checks for last, because they're the most expensive + return isOverflowingAndTabbable(el); } /** diff --git a/index.html b/index.html index a9be357..05d6dc1 100644 --- a/index.html +++ b/index.html @@ -228,6 +228,115 @@



+ +

+ Handling overflow elements +

+ + + +
+ + Non-overflowing w/ overflow: auto; +
+ +
+ +

+ + Overflowing w/ overflow: auto; +
+
+ + + + + + + + + + + + + + + + + + + + +
+ +

+ + overflow scroll +
+
+ +
+ +

+ + overflow scroll +
+
+ + + + + + + + + + + + + + + + + + + + +
+ +

+ + overflow: hidden; +
+
+ + + + + + + + + + + + + + + + + + + + +
+ +

+ + +