,
+ )
+
+ const focusZoneContainer = container.querySelector('#focusZone')!
+ const allButtons = focusZoneContainer.querySelectorAll('button')
+ const firstButton = allButtons[0]
+ const lastButton = allButtons[allButtons.length - 1]
+ const controller = focusZone(focusZoneContainer, {onlyTabbable: true})
+
+ firstButton.focus()
+ expect(document.activeElement).toEqual(firstButton)
+
+ await user.keyboard('{arrowdown}')
+ expect(document.activeElement).toEqual(lastButton)
+
+ controller.abort()
+})
diff --git a/src/__tests__/iterate-focusable-elements.test.tsx b/src/__tests__/iterate-focusable-elements.test.tsx
index 7c309cc..36bad14 100644
--- a/src/__tests__/iterate-focusable-elements.test.tsx
+++ b/src/__tests__/iterate-focusable-elements.test.tsx
@@ -2,6 +2,46 @@ import React from 'react'
import {iterateFocusableElements} from '../utils/iterate-focusable-elements.js'
import {render} from '@testing-library/react'
+// Since we use strict checks for size and parent, we need to mock these
+// properties that Jest does not populate.
+beforeAll(() => {
+ try {
+ Object.defineProperties(HTMLElement.prototype, {
+ offsetHeight: {
+ get: () => 42,
+ },
+ offsetWidth: {
+ get: () => 42,
+ },
+ getClientRects: {
+ get: () => () => [42],
+ },
+ offsetParent: {
+ get() {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ for (let element = this; element; element = element.parentNode) {
+ if (element.style?.display?.toLowerCase() === 'none') {
+ return null
+ }
+ }
+
+ if (this.style?.position?.toLowerCase() === 'fixed') {
+ return null
+ }
+
+ if (this.tagName.toLowerCase() in ['html', 'body']) {
+ return null
+ }
+
+ return this.parentNode
+ },
+ },
+ })
+ } catch {
+ // ignore
+ }
+})
+
it('Should iterate through focusable elements only', () => {
const {container} = render(
@@ -57,3 +97,30 @@ it('Should iterate through focusable elements in reverse', () => {
expect(focusable[3].tagName.toLowerCase()).toEqual('input')
expect(focusable[4].tagName.toLowerCase()).toEqual('textarea')
})
+
+it('Should ignore hidden elements if strict', async () => {
+ const {container} = render(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
,
+ )
+ const focusable = Array.from(iterateFocusableElements(container as HTMLElement, {strict: true}))
+ expect(focusable.length).toEqual(2)
+ expect(focusable[0].tagName.toLowerCase()).toEqual('button')
+ expect(focusable[0].textContent).toEqual('Apple')
+ expect(focusable[1].tagName.toLowerCase()).toEqual('button')
+ expect(focusable[1].textContent).toEqual('Cantaloupe')
+})
diff --git a/src/focus-zone.ts b/src/focus-zone.ts
index c4bbcb4..157c42d 100644
--- a/src/focus-zone.ts
+++ b/src/focus-zone.ts
@@ -1,6 +1,6 @@
import {polyfill as eventListenerSignalPolyfill} from './polyfills/event-listener-signal.js'
import {isMacOS} from './utils/user-agent.js'
-import {iterateFocusableElements} from './utils/iterate-focusable-elements.js'
+import {IterateFocusableElements, iterateFocusableElements} from './utils/iterate-focusable-elements.js'
import {uniqueId} from './utils/unique-id.js'
eventListenerSignalPolyfill()
@@ -114,7 +114,7 @@ const KEY_TO_DIRECTION = {
/**
* Options that control the behavior of the arrow focus behavior.
*/
-export interface FocusZoneSettings {
+export type FocusZoneSettings = IterateFocusableElements & {
/**
* Choose the behavior applied in cases where focus is currently at either the first or
* last element of the container.
@@ -504,8 +504,13 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
}
}
+ const iterateFocusableElementsOptions: IterateFocusableElements = {
+ reverse: settings?.reverse,
+ strict: settings?.strict,
+ onlyTabbable: settings?.onlyTabbable,
+ }
// Take all tabbable elements within container under management
- beginFocusManagement(...iterateFocusableElements(container))
+ beginFocusManagement(...iterateFocusableElements(container, iterateFocusableElementsOptions))
// Open the first tabbable element for tabbing
const initialElement =
@@ -519,14 +524,14 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
for (const mutation of mutations) {
for (const removedNode of mutation.removedNodes) {
if (removedNode instanceof HTMLElement) {
- endFocusManagement(...iterateFocusableElements(removedNode))
+ endFocusManagement(...iterateFocusableElements(removedNode, iterateFocusableElementsOptions))
}
}
}
for (const mutation of mutations) {
for (const addedNode of mutation.addedNodes) {
if (addedNode instanceof HTMLElement) {
- beginFocusManagement(...iterateFocusableElements(addedNode))
+ beginFocusManagement(...iterateFocusableElements(addedNode, iterateFocusableElementsOptions))
}
}
}
diff --git a/src/utils/iterate-focusable-elements.ts b/src/utils/iterate-focusable-elements.ts
index 8dacb5d..3a4a58d 100644
--- a/src/utils/iterate-focusable-elements.ts
+++ b/src/utils/iterate-focusable-elements.ts
@@ -98,10 +98,12 @@ export function isFocusable(elem: HTMLElement, strict = false): boolean {
// Each of the conditions checked below require a reflow, thus are gated by the `strict`
// argument. If any are true, the element is not focusable, even if tabindex is set.
if (strict) {
+ const style = getComputedStyle(elem)
const sizeInert = elem.offsetWidth === 0 || elem.offsetHeight === 0
- const visibilityInert = ['hidden', 'collapse'].includes(getComputedStyle(elem).visibility)
+ const visibilityInert = ['hidden', 'collapse'].includes(style.visibility)
+ const displayInert = style.display === 'none' || !elem.offsetParent
const clientRectsInert = elem.getClientRects().length === 0
- if (sizeInert || visibilityInert || clientRectsInert) {
+ if (sizeInert || visibilityInert || clientRectsInert || displayInert) {
return false
}
}