Skip to content

Commit 3463e25

Browse files
authored
Merge pull request #235 from primer/jellobagel-focus-zone-options
Add IterateFocusableElements options to focusZone
2 parents e1a8175 + 00478de commit 3463e25

6 files changed

+192
-13
lines changed

.changeset/neat-timers-tease.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/behaviors': minor
3+
---
4+
5+
Add IterateFocusableElements options to focusZone

src/__tests__/focus-trap.test.tsx

+26-6
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,26 @@ beforeAll(() => {
1717
getClientRects: {
1818
get: () => () => [42],
1919
},
20+
offsetParent: {
21+
get() {
22+
// eslint-disable-next-line @typescript-eslint/no-this-alias
23+
for (let element = this; element; element = element.parentNode) {
24+
if (element.style?.display?.toLowerCase() === 'none') {
25+
return null
26+
}
27+
}
28+
29+
if (this.style?.position?.toLowerCase() === 'fixed') {
30+
return null
31+
}
32+
33+
if (this.tagName.toLowerCase() in ['html', 'body']) {
34+
return null
35+
}
36+
37+
return this.parentNode
38+
},
39+
},
2040
})
2141
} catch {
2242
// ignore
@@ -40,7 +60,7 @@ it('Should initially focus the first element when activated', () => {
4060
const controller = focusTrap(trapContainer)
4161
expect(document.activeElement).toEqual(firstButton)
4262

43-
controller.abort()
63+
controller?.abort()
4464
})
4565

4666
it('Should initially focus the initialFocus element when specified', () => {
@@ -57,7 +77,7 @@ it('Should initially focus the initialFocus element when specified', () => {
5777
const controller = focusTrap(trapContainer, secondButton)
5878
expect(document.activeElement).toEqual(secondButton)
5979

60-
controller.abort()
80+
controller?.abort()
6181
})
6282

6383
it('Should prevent focus from exiting the trap, returns focus to first element', async () => {
@@ -86,7 +106,7 @@ it('Should prevent focus from exiting the trap, returns focus to first element',
86106
await user.tab()
87107
expect(document.activeElement).toEqual(firstButton)
88108

89-
controller.abort()
109+
controller?.abort()
90110

91111
lastButton.focus()
92112
await user.tab()
@@ -125,7 +145,7 @@ it('Should cycle focus from last element to first element and vice-versa', async
125145
await user.tab({shift: true})
126146
expect(document.activeElement).toEqual(lastButton)
127147

128-
controller.abort()
148+
controller?.abort()
129149
})
130150

131151
it('Should should release the trap when the signal is aborted', async () => {
@@ -154,7 +174,7 @@ it('Should should release the trap when the signal is aborted', async () => {
154174
await user.tab()
155175
expect(document.activeElement).toEqual(firstButton)
156176

157-
controller.abort()
177+
controller?.abort()
158178

159179
lastButton.focus()
160180
await user.tab()
@@ -218,5 +238,5 @@ it('Should handle dynamic content', async () => {
218238
await user.tab({shift: true})
219239
expect(document.activeElement).toEqual(secondButton)
220240

221-
controller.abort()
241+
controller?.abort()
222242
})

src/__tests__/focus-zone.test.tsx

+80
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@ beforeAll(() => {
2121
getClientRects: {
2222
get: () => () => [42],
2323
},
24+
offsetParent: {
25+
get() {
26+
// eslint-disable-next-line @typescript-eslint/no-this-alias
27+
for (let element = this; element; element = element.parentNode) {
28+
if (element.style?.display?.toLowerCase() === 'none') {
29+
return null
30+
}
31+
}
32+
33+
if (this.style?.position?.toLowerCase() === 'fixed') {
34+
return null
35+
}
36+
37+
if (this.tagName.toLowerCase() in ['html', 'body']) {
38+
return null
39+
}
40+
41+
return this.parentNode
42+
},
43+
},
2444
})
2545
} catch {
2646
// ignore
@@ -569,3 +589,63 @@ it('Should handle elements being reordered', async () => {
569589

570590
controller.abort()
571591
})
592+
593+
it('Should ignore hidden elements if strict', async () => {
594+
const user = userEvent.setup()
595+
const {container} = render(
596+
<div id="focusZone">
597+
<button>Apple</button>
598+
<button style={{visibility: 'hidden'}}>Banana</button>
599+
<button style={{display: 'none'}}>Watermelon</button>
600+
<div style={{visibility: 'hidden'}}>
601+
<div>
602+
<button>Cherry</button>
603+
</div>
604+
</div>
605+
<div style={{display: 'none'}}>
606+
<div>
607+
<button>Peach</button>
608+
</div>
609+
</div>
610+
<button tabIndex={-1}>Cantaloupe</button>
611+
</div>,
612+
)
613+
const focusZoneContainer = container.querySelector<HTMLElement>('#focusZone')!
614+
const allButtons = focusZoneContainer.querySelectorAll('button')
615+
const firstButton = allButtons[0]
616+
const lastButton = allButtons[allButtons.length - 1]
617+
const controller = focusZone(focusZoneContainer, {strict: true})
618+
619+
firstButton.focus()
620+
expect(document.activeElement).toEqual(firstButton)
621+
622+
await user.keyboard('{arrowdown}')
623+
expect(document.activeElement).toEqual(lastButton)
624+
625+
controller.abort()
626+
})
627+
628+
it('Shoud move to tabbable elements if onlyTabbable', async () => {
629+
const user = userEvent.setup()
630+
const {container} = render(
631+
<div id="focusZone">
632+
<button>Apple</button>
633+
<button tabIndex={-1}>Cherry</button>
634+
<button tabIndex={0}>Cantaloupe</button>
635+
</div>,
636+
)
637+
638+
const focusZoneContainer = container.querySelector<HTMLElement>('#focusZone')!
639+
const allButtons = focusZoneContainer.querySelectorAll('button')
640+
const firstButton = allButtons[0]
641+
const lastButton = allButtons[allButtons.length - 1]
642+
const controller = focusZone(focusZoneContainer, {onlyTabbable: true})
643+
644+
firstButton.focus()
645+
expect(document.activeElement).toEqual(firstButton)
646+
647+
await user.keyboard('{arrowdown}')
648+
expect(document.activeElement).toEqual(lastButton)
649+
650+
controller.abort()
651+
})

src/__tests__/iterate-focusable-elements.test.tsx

+67
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,46 @@ import React from 'react'
22
import {iterateFocusableElements} from '../utils/iterate-focusable-elements.js'
33
import {render} from '@testing-library/react'
44

5+
// Since we use strict checks for size and parent, we need to mock these
6+
// properties that Jest does not populate.
7+
beforeAll(() => {
8+
try {
9+
Object.defineProperties(HTMLElement.prototype, {
10+
offsetHeight: {
11+
get: () => 42,
12+
},
13+
offsetWidth: {
14+
get: () => 42,
15+
},
16+
getClientRects: {
17+
get: () => () => [42],
18+
},
19+
offsetParent: {
20+
get() {
21+
// eslint-disable-next-line @typescript-eslint/no-this-alias
22+
for (let element = this; element; element = element.parentNode) {
23+
if (element.style?.display?.toLowerCase() === 'none') {
24+
return null
25+
}
26+
}
27+
28+
if (this.style?.position?.toLowerCase() === 'fixed') {
29+
return null
30+
}
31+
32+
if (this.tagName.toLowerCase() in ['html', 'body']) {
33+
return null
34+
}
35+
36+
return this.parentNode
37+
},
38+
},
39+
})
40+
} catch {
41+
// ignore
42+
}
43+
})
44+
545
it('Should iterate through focusable elements only', () => {
646
const {container} = render(
747
<div>
@@ -57,3 +97,30 @@ it('Should iterate through focusable elements in reverse', () => {
5797
expect(focusable[3].tagName.toLowerCase()).toEqual('input')
5898
expect(focusable[4].tagName.toLowerCase()).toEqual('textarea')
5999
})
100+
101+
it('Should ignore hidden elements if strict', async () => {
102+
const {container} = render(
103+
<div>
104+
<button>Apple</button>
105+
<button style={{visibility: 'hidden'}}>Banana</button>
106+
<button style={{display: 'none'}}>Watermelon</button>
107+
<div style={{visibility: 'hidden'}}>
108+
<div>
109+
<button>Cherry</button>
110+
</div>
111+
</div>
112+
<div style={{display: 'none'}}>
113+
<div>
114+
<button>Peach</button>
115+
</div>
116+
</div>
117+
<button tabIndex={-1}>Cantaloupe</button>
118+
</div>,
119+
)
120+
const focusable = Array.from(iterateFocusableElements(container as HTMLElement, {strict: true}))
121+
expect(focusable.length).toEqual(2)
122+
expect(focusable[0].tagName.toLowerCase()).toEqual('button')
123+
expect(focusable[0].textContent).toEqual('Apple')
124+
expect(focusable[1].tagName.toLowerCase()).toEqual('button')
125+
expect(focusable[1].textContent).toEqual('Cantaloupe')
126+
})

src/focus-zone.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {polyfill as eventListenerSignalPolyfill} from './polyfills/event-listener-signal.js'
22
import {isMacOS} from './utils/user-agent.js'
3-
import {iterateFocusableElements} from './utils/iterate-focusable-elements.js'
3+
import {IterateFocusableElements, iterateFocusableElements} from './utils/iterate-focusable-elements.js'
44
import {uniqueId} from './utils/unique-id.js'
55

66
eventListenerSignalPolyfill()
@@ -114,7 +114,7 @@ const KEY_TO_DIRECTION = {
114114
/**
115115
* Options that control the behavior of the arrow focus behavior.
116116
*/
117-
export interface FocusZoneSettings {
117+
export type FocusZoneSettings = IterateFocusableElements & {
118118
/**
119119
* Choose the behavior applied in cases where focus is currently at either the first or
120120
* last element of the container.
@@ -504,8 +504,13 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
504504
}
505505
}
506506

507+
const iterateFocusableElementsOptions: IterateFocusableElements = {
508+
reverse: settings?.reverse,
509+
strict: settings?.strict,
510+
onlyTabbable: settings?.onlyTabbable,
511+
}
507512
// Take all tabbable elements within container under management
508-
beginFocusManagement(...iterateFocusableElements(container))
513+
beginFocusManagement(...iterateFocusableElements(container, iterateFocusableElementsOptions))
509514

510515
// Open the first tabbable element for tabbing
511516
const initialElement =
@@ -519,14 +524,14 @@ export function focusZone(container: HTMLElement, settings?: FocusZoneSettings):
519524
for (const mutation of mutations) {
520525
for (const removedNode of mutation.removedNodes) {
521526
if (removedNode instanceof HTMLElement) {
522-
endFocusManagement(...iterateFocusableElements(removedNode))
527+
endFocusManagement(...iterateFocusableElements(removedNode, iterateFocusableElementsOptions))
523528
}
524529
}
525530
}
526531
for (const mutation of mutations) {
527532
for (const addedNode of mutation.addedNodes) {
528533
if (addedNode instanceof HTMLElement) {
529-
beginFocusManagement(...iterateFocusableElements(addedNode))
534+
beginFocusManagement(...iterateFocusableElements(addedNode, iterateFocusableElementsOptions))
530535
}
531536
}
532537
}

src/utils/iterate-focusable-elements.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,12 @@ export function isFocusable(elem: HTMLElement, strict = false): boolean {
9898
// Each of the conditions checked below require a reflow, thus are gated by the `strict`
9999
// argument. If any are true, the element is not focusable, even if tabindex is set.
100100
if (strict) {
101+
const style = getComputedStyle(elem)
101102
const sizeInert = elem.offsetWidth === 0 || elem.offsetHeight === 0
102-
const visibilityInert = ['hidden', 'collapse'].includes(getComputedStyle(elem).visibility)
103+
const visibilityInert = ['hidden', 'collapse'].includes(style.visibility)
104+
const displayInert = style.display === 'none' || !elem.offsetParent
103105
const clientRectsInert = elem.getClientRects().length === 0
104-
if (sizeInert || visibilityInert || clientRectsInert) {
106+
if (sizeInert || visibilityInert || clientRectsInert || displayInert) {
105107
return false
106108
}
107109
}

0 commit comments

Comments
 (0)