Skip to content

Commit 106072a

Browse files
committed
fix(tab): make the events more correct
1 parent 41432e5 commit 106072a

File tree

3 files changed

+142
-15
lines changed

3 files changed

+142
-15
lines changed

src/user-event/__tests__/tab.js

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,85 @@
11
import {userEvent} from '../../'
2-
import {setup} from './helpers/utils'
2+
import {setup, addListeners} from './helpers/utils'
3+
4+
test('fires events when tabbing between two elements', async () => {
5+
const {element, getEventSnapshot, clearEventCalls} = setup(
6+
`<div><input id="a" /><input id="b" /></div>`,
7+
)
8+
const a = element.children[0]
9+
const b = element.children[1]
10+
await userEvent.focus(a)
11+
clearEventCalls()
12+
13+
const aListeners = addListeners(a)
14+
const bListeners = addListeners(b)
15+
16+
await userEvent.tab()
17+
expect(getEventSnapshot()).toMatchInlineSnapshot(`
18+
Events fired on: div
19+
20+
input#a[value=""] - keydown: Tab (9)
21+
input#a[value=""] - focusout
22+
input#b[value=""] - focusin
23+
input#b[value=""] - keyup: Tab (9)
24+
`)
25+
// blur/focus do not bubble
26+
expect(aListeners.eventWasFired('blur')).toBe(true)
27+
expect(bListeners.eventWasFired('focus')).toBe(true)
28+
})
29+
30+
test('does not change focus if default prevented on keydown', async () => {
31+
const {element, getEventSnapshot, clearEventCalls} = setup(
32+
`<div><input id="a" /><input id="b" /></div>`,
33+
)
34+
const a = element.children[0]
35+
const b = element.children[1]
36+
await userEvent.focus(a)
37+
clearEventCalls()
38+
39+
const aListeners = addListeners(a, {
40+
eventHandlers: {keyDown: e => e.preventDefault()},
41+
})
42+
const bListeners = addListeners(b)
43+
44+
await userEvent.tab()
45+
expect(getEventSnapshot()).toMatchInlineSnapshot(`
46+
Events fired on: div
47+
48+
input#a[value=""] - keydown: Tab (9)
49+
input#a[value=""] - keyup: Tab (9)
50+
`)
51+
// blur/focus do not bubble
52+
expect(aListeners.eventWasFired('blur')).toBe(false)
53+
expect(bListeners.eventWasFired('focus')).toBe(false)
54+
})
55+
56+
test('fires correct events with shift key', async () => {
57+
const {element, getEventSnapshot, clearEventCalls} = setup(
58+
`<div><input id="a" /><input id="b" /></div>`,
59+
)
60+
const a = element.children[0]
61+
const b = element.children[1]
62+
await userEvent.focus(b)
63+
clearEventCalls()
64+
65+
const aListeners = addListeners(a)
66+
const bListeners = addListeners(b)
67+
68+
await userEvent.tab({shift: true})
69+
expect(getEventSnapshot()).toMatchInlineSnapshot(`
70+
Events fired on: div
71+
72+
input#b[value=""] - keydown: Shift (16) {shift}
73+
input#b[value=""] - keydown: Tab (9) {shift}
74+
input#b[value=""] - focusout
75+
input#a[value=""] - focusin
76+
input#a[value=""] - keyup: Tab (9) {shift}
77+
input#a[value=""] - keyup: Shift (16)
78+
`)
79+
// blur/focus do not bubble
80+
expect(aListeners.eventWasFired('focus')).toBe(true)
81+
expect(bListeners.eventWasFired('blur')).toBe(true)
82+
})
383

484
test('should cycle elements in document tab order', async () => {
585
setup(`

src/user-event/tab.js

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import {wrapAsync} from '../wrap-async'
2-
import {tick} from './tick'
3-
import {FOCUSABLE_SELECTOR} from './utils'
2+
import {fireEvent, getActiveElement, FOCUSABLE_SELECTOR} from './utils'
3+
import {focus} from './focus'
4+
import {blur} from './blur'
45

5-
async function tab({shift = false, focusTrap = document} = {}) {
6-
// everything in user-event must be actually async, but since we're not
7-
// calling fireEvent in here, we'll add this tick here...
8-
await tick()
6+
async function tab({shift = false, focusTrap} = {}) {
7+
const previousElement = getActiveElement(focusTrap?.ownerDocument ?? document)
8+
9+
if (!focusTrap) {
10+
focusTrap = document
11+
}
912

1013
const focusableElements = focusTrap.querySelectorAll(FOCUSABLE_SELECTOR)
1114

@@ -57,16 +60,60 @@ async function tab({shift = false, focusTrap = document} = {}) {
5760
const nextIndex = shift ? index - 1 : index + 1
5861
const defaultIndex = shift ? prunedElements.length - 1 : 0
5962

60-
const next = prunedElements[nextIndex] || prunedElements[defaultIndex]
63+
const nextElement = prunedElements[nextIndex] || prunedElements[defaultIndex]
64+
65+
const shiftKeyInit = {
66+
key: 'Shift',
67+
keyCode: 16,
68+
shiftKey: true,
69+
}
70+
const tabKeyInit = {
71+
key: 'Tab',
72+
keyCode: 9,
73+
shiftKey: shift,
74+
}
75+
76+
let continueToTab = true
77+
78+
// not sure how to make it so there's no previous element...
79+
// istanbul ignore else
80+
if (previousElement) {
81+
// preventDefault on the shift key makes no difference
82+
if (shift) await fireEvent.keyDown(previousElement, {...shiftKeyInit})
83+
continueToTab = await fireEvent.keyDown(previousElement, {...tabKeyInit})
84+
if (continueToTab) {
85+
await blur(previousElement)
86+
}
87+
}
88+
89+
const keyUpTarget =
90+
!continueToTab && previousElement ? previousElement : nextElement
91+
92+
if (continueToTab) {
93+
const hasTabIndex = nextElement.getAttribute('tabindex') !== null
94+
if (!hasTabIndex) {
95+
nextElement.setAttribute('tabindex', '0') // jsdom requires tabIndex=0 for an item to become 'document.activeElement'
96+
}
6197

62-
if (next.getAttribute('tabindex') === null) {
63-
next.setAttribute('tabindex', '0') // jsdom requires tabIndex=0 for an item to become 'document.activeElement'
64-
next.focus()
65-
next.removeAttribute('tabindex') // leave no trace. :)
66-
} else {
67-
next.focus()
98+
await focus(nextElement)
99+
100+
if (!hasTabIndex) {
101+
nextElement.removeAttribute('tabindex') // leave no trace. :)
102+
}
103+
}
104+
105+
await fireEvent.keyUp(keyUpTarget, {...tabKeyInit})
106+
107+
if (shift) {
108+
await fireEvent.keyUp(keyUpTarget, {...shiftKeyInit, shiftKey: false})
68109
}
69110
}
70111
tab = wrapAsync(tab)
71112

72113
export {tab}
114+
115+
/*
116+
eslint
117+
complexity: "off",
118+
max-statements: "off",
119+
*/

src/user-event/utils.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ const FOCUSABLE_SELECTOR = [
125125
function isFocusable(element) {
126126
return (
127127
!isLabelWithInternallyDisabledControl(element) &&
128-
element.matches(FOCUSABLE_SELECTOR)
128+
element?.matches(FOCUSABLE_SELECTOR)
129129
)
130130
}
131131

0 commit comments

Comments
 (0)