Skip to content

Commit e83d949

Browse files
ph-fritscheBen Styles
and
Ben Styles
authored
fix(tab): exclude hidden descendants (#579)
* Don't allow tabbing to elements inside hidden parents * fix: use isVisible helper to exclude hidden elements Hidden elements should be excluded from tab order. Hidden attribute can be overwritten by stylesheets. Co-authored-by: Ben Styles <[email protected]>
1 parent 31cd4cf commit e83d949

File tree

4 files changed

+60
-5
lines changed

4 files changed

+60
-5
lines changed

src/__tests__/tab.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ test('fires events when tabbing between two elements', () => {
1717
userEvent.tab()
1818
expect(getEventSnapshot()).toMatchInlineSnapshot(`
1919
Events fired on: div
20-
20+
2121
input#a[value=""] - keydown: Tab (9)
2222
input#a[value=""] - focusout
2323
input#b[value=""] - focusin
@@ -45,7 +45,7 @@ test('does not change focus if default prevented on keydown', () => {
4545
userEvent.tab()
4646
expect(getEventSnapshot()).toMatchInlineSnapshot(`
4747
Events fired on: div
48-
48+
4949
input#a[value=""] - keydown: Tab (9)
5050
input#a[value=""] - keyup: Tab (9)
5151
`)
@@ -418,6 +418,26 @@ test('should not focus disabled elements', () => {
418418
expect(five).toHaveFocus()
419419
})
420420

421+
test('should not focus elements inside a hidden parent', () => {
422+
setup(`
423+
<div>
424+
<input data-testid="one" />
425+
<div hidden="">
426+
<button>click</button>
427+
</div>
428+
<input data-testid="three" />
429+
</div>`)
430+
431+
const one = document.querySelector('[data-testid="one"]')
432+
const three = document.querySelector('[data-testid="three"]')
433+
434+
userEvent.tab()
435+
expect(one).toHaveFocus()
436+
437+
userEvent.tab()
438+
expect(three).toHaveFocus()
439+
})
440+
421441
test('should keep focus on the document if there are no enabled, focusable elements', () => {
422442
setup(`<button disabled>no clicky</button>`)
423443
userEvent.tab()

src/__tests__/utils.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import {isInstanceOfElement} from '../utils'
1+
import { screen } from '@testing-library/dom'
2+
import {isInstanceOfElement, isVisible} from '../utils'
23
import {setup} from './helpers/utils'
34

45
// isInstanceOfElement can be removed once the peerDependency for @testing-library/dom is bumped to a version that includes https://github.com/testing-library/dom-testing-library/pull/885
@@ -71,3 +72,19 @@ describe('check element type per isInstanceOfElement', () => {
7172
expect(() => isInstanceOfElement(element, 'HTMLSpanElement')).toThrow()
7273
})
7374
})
75+
76+
test('check if element is visible', () => {
77+
setup(`
78+
<input data-testid="visibleInput"/>
79+
<input data-testid="hiddenInput" hidden/>
80+
<input data-testid="styledHiddenInput" style="display: none">
81+
<input data-testid="styledDisplayedInput" hidden style="display: block"/>
82+
<div style="display: none"><input data-testid="childInput" /></div>
83+
`)
84+
85+
expect(isVisible(screen.getByTestId('visibleInput'))).toBe(true)
86+
expect(isVisible(screen.getByTestId('styledDisplayedInput'))).toBe(true)
87+
expect(isVisible(screen.getByTestId('styledHiddenInput'))).toBe(false)
88+
expect(isVisible(screen.getByTestId('childInput'))).toBe(false)
89+
expect(isVisible(screen.getByTestId('hiddenInput'))).toBe(false)
90+
})

src/tab.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {fireEvent} from '@testing-library/dom'
2-
import {getActiveElement, FOCUSABLE_SELECTOR} from './utils'
2+
import {getActiveElement, FOCUSABLE_SELECTOR, isVisible} from './utils'
33
import {focus} from './focus'
44
import {blur} from './blur'
55

@@ -31,7 +31,11 @@ function tab({shift = false, focusTrap} = {}) {
3131
const enabledElements = [...focusableElements].filter(
3232
el =>
3333
el === previousElement ||
34-
(el.getAttribute('tabindex') !== '-1' && !el.disabled),
34+
(el.getAttribute('tabindex') !== '-1' &&
35+
!el.disabled &&
36+
// Hidden elements are not tabable
37+
isVisible(el)
38+
),
3539
)
3640

3741
if (enabledElements.length === 0) return

src/utils.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,19 @@ function isClickableInput(element) {
293293
)
294294
}
295295

296+
function isVisible(element) {
297+
const getComputedStyle = getWindowFromNode(element).getComputedStyle
298+
299+
for(; element && element.ownerDocument; element = element.parentNode) {
300+
const display = getComputedStyle(element).display
301+
if (display === 'none') {
302+
return false
303+
}
304+
}
305+
306+
return true
307+
}
308+
296309
function eventWrapper(cb) {
297310
let result
298311
getConfig().eventWrapper(() => {
@@ -367,4 +380,5 @@ export {
367380
getSelectionRange,
368381
isContentEditable,
369382
isInstanceOfElement,
383+
isVisible,
370384
}

0 commit comments

Comments
 (0)