Skip to content

Commit 4459689

Browse files
committed
handle keyboard interactions in a more robust way
Browsers. Are. Crazy. In JSDOM, when you fire an event, you only get that specific event. You don't get all the magic that the browser gives you. For example, when you are focused on a button and press to "Tab" then in JSDOM you would only get a keydown event. However in the browser you get this chain of events: 1. `keydown` on the current element 2. `blur` on the current element 3. `focus` on the new element 4. `keyup` on the new element I implemented this "magic", for the `Tab`, `Enter` and `Space` key for now. Those are the most important currently. `Enter` and `Space` also trigger `click` events for example. I also have a "generic" implementation, where a normal press results in: 1. `keydown` 2. `keypress` (in case it has a `charCode` and is "printable", so `alt` is ignored) 3. `keyup` I also ensured that the cancelation when you use an `event.preventDefault()` happens correctly. Here is a fun summary: https://twitter.com/malfaitrobin/status/1354472678128820234 Press "Enter" on a button -> keydown, keypress, click, keyup Press "Space" on a button -> keydown, keypress, keyup, click Press "Enter" or "Space" on a button, with event.preventDefault() in the keydown listener -> keydown, keyup Press "Enter" on a button, with event.preventDefault() in the keypress listener -> keydown, keypress, keyup Press "Space" on a button, with event.preventDefault() in the keypress listener -> keydown, keypress, keyup, click
1 parent b6212b9 commit 4459689

File tree

6 files changed

+645
-46
lines changed

6 files changed

+645
-46
lines changed
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import React from 'react'
2+
import { render } from '@testing-library/react'
3+
4+
import { type, shift, Keys } from './interactions'
5+
6+
type Events = 'onKeyDown' | 'onKeyUp' | 'onKeyPress' | 'onClick' | 'onBlur' | 'onFocus'
7+
let events: Events[] = ['onKeyDown', 'onKeyUp', 'onKeyPress', 'onClick', 'onBlur', 'onFocus']
8+
9+
type Args = [
10+
string | Partial<KeyboardEvent>,
11+
(string | Partial<KeyboardEvent | MouseEvent>)[],
12+
Set<Events>
13+
]
14+
15+
function key(input: string | Partial<KeyboardEvent>): Partial<KeyboardEvent> {
16+
if (typeof input === 'string') return { key: input }
17+
return input
18+
}
19+
20+
function event(
21+
input: string | Partial<KeyboardEvent | MouseEvent>,
22+
target?: string
23+
): Partial<KeyboardEvent | MouseEvent> {
24+
let e = typeof input === 'string' ? { type: input } : input
25+
26+
if (target) {
27+
Object.defineProperty(e, 'target', {
28+
configurable: false,
29+
enumerable: true,
30+
get() {
31+
return document.getElementById(target!)
32+
},
33+
})
34+
}
35+
36+
return e
37+
}
38+
39+
describe('Keyboard', () => {
40+
describe('type', () => {
41+
it.each<Args>([
42+
// Default - no cancellation
43+
['a', ['keydown', 'keypress', 'keyup'], new Set()],
44+
[Keys.Space, ['keydown', 'keypress', 'keyup', 'click'], new Set()],
45+
[Keys.Enter, ['keydown', 'keypress', 'click', 'keyup'], new Set()],
46+
[
47+
Keys.Tab,
48+
[
49+
event('keydown', 'trigger'),
50+
event('blur', 'trigger'),
51+
event('focus', 'after'),
52+
event('keyup', 'after'),
53+
],
54+
new Set(),
55+
],
56+
[
57+
shift(Keys.Tab),
58+
[
59+
event('keydown', 'trigger'),
60+
event('blur', 'trigger'),
61+
event('focus', 'before'),
62+
event('keyup', 'before'),
63+
],
64+
new Set(),
65+
],
66+
67+
// Canceling keydown
68+
['a', ['keydown', 'keyup'], new Set<Events>(['onKeyDown'])],
69+
[Keys.Space, ['keydown', 'keyup'], new Set<Events>(['onKeyDown'])],
70+
[Keys.Enter, ['keydown', 'keyup'], new Set<Events>(['onKeyDown'])],
71+
[Keys.Tab, ['keydown', 'keyup'], new Set<Events>(['onKeyDown'])],
72+
[shift(Keys.Tab), ['keydown', 'keyup'], new Set<Events>(['onKeyDown'])],
73+
74+
// Canceling keypress
75+
['a', ['keydown', 'keypress', 'keyup'], new Set<Events>(['onKeyPress'])],
76+
[Keys.Space, ['keydown', 'keypress', 'keyup', 'click'], new Set<Events>(['onKeyPress'])],
77+
[Keys.Enter, ['keydown', 'keypress', 'keyup'], new Set<Events>(['onKeyPress'])],
78+
[
79+
Keys.Tab,
80+
[
81+
event('keydown', 'trigger'),
82+
event('blur', 'trigger'),
83+
event('focus', 'after'),
84+
event('keyup', 'after'),
85+
],
86+
new Set<Events>(['onKeyPress']),
87+
],
88+
[
89+
shift(Keys.Tab),
90+
[
91+
event('keydown', 'trigger'),
92+
event('blur', 'trigger'),
93+
event('focus', 'before'),
94+
event('keyup', 'before'),
95+
],
96+
new Set<Events>(['onKeyPress']),
97+
],
98+
99+
// Canceling keyup
100+
['a', ['keydown', 'keypress', 'keyup'], new Set<Events>(['onKeyUp'])],
101+
[Keys.Space, ['keydown', 'keypress', 'keyup'], new Set<Events>(['onKeyUp'])],
102+
[Keys.Enter, ['keydown', 'keypress', 'click', 'keyup'], new Set<Events>(['onKeyUp'])],
103+
[
104+
Keys.Tab,
105+
[
106+
event('keydown', 'trigger'),
107+
event('blur', 'trigger'),
108+
event('focus', 'after'),
109+
event('keyup', 'after'),
110+
],
111+
new Set<Events>(['onKeyUp']),
112+
],
113+
[
114+
shift(Keys.Tab),
115+
[
116+
event('keydown', 'trigger'),
117+
event('blur', 'trigger'),
118+
event('focus', 'before'),
119+
event('keyup', 'before'),
120+
],
121+
new Set<Events>(['onKeyUp']),
122+
],
123+
124+
// Cancelling blur
125+
[
126+
Keys.Tab,
127+
[
128+
event('keydown', 'trigger'),
129+
event('blur', 'trigger'),
130+
event('focus', 'after'),
131+
event('keyup', 'after'),
132+
],
133+
new Set<Events>(['onBlur']),
134+
],
135+
[
136+
shift(Keys.Tab),
137+
[
138+
event('keydown', 'trigger'),
139+
event('blur', 'trigger'),
140+
event('focus', 'before'),
141+
event('keyup', 'before'),
142+
],
143+
new Set<Events>(['onBlur']),
144+
],
145+
])('should fire the correct events %#', async (input, result, prevents) => {
146+
let fired: (KeyboardEvent | MouseEvent)[] = []
147+
148+
let state = { readyToCapture: false }
149+
150+
function createProps(id: string) {
151+
return events.reduce(
152+
(props: React.ComponentProps<'button'>, name) => {
153+
props[name] = (event: any) => {
154+
if (!state.readyToCapture) return
155+
if (prevents.has(name)) event.preventDefault()
156+
fired.push(event.nativeEvent)
157+
}
158+
return props
159+
},
160+
{ id }
161+
)
162+
}
163+
164+
render(
165+
<>
166+
<button {...createProps('before')}>Before</button>
167+
<button {...createProps('trigger')}>Trigger</button>
168+
<button {...createProps('after')}>After</button>
169+
</>
170+
)
171+
172+
let trigger = document.getElementById('trigger')
173+
trigger?.focus()
174+
state.readyToCapture = true
175+
176+
await type([key(input)])
177+
178+
let expected = result.map(e => event(e))
179+
180+
expect(fired.length).toEqual(result.length)
181+
182+
for (let [idx, event] of fired.entries()) {
183+
for (let key in expected[idx]) {
184+
let _key = key as keyof (KeyboardEvent | MouseEvent)
185+
expect(event[_key]).toBe(expected[idx][_key])
186+
}
187+
}
188+
})
189+
})
190+
})

packages/@headlessui-react/src/test-utils/interactions.ts

Lines changed: 118 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ function nextFrame(cb: Function): void {
99
}
1010

1111
export const Keys: Record<string, Partial<KeyboardEvent>> = {
12-
Space: { key: ' ', keyCode: 32 },
13-
Enter: { key: 'Enter', keyCode: 13 },
14-
Escape: { key: 'Escape', keyCode: 27 },
12+
Space: { key: ' ', keyCode: 32, charCode: 32 },
13+
Enter: { key: 'Enter', keyCode: 13, charCode: 13 },
14+
Escape: { key: 'Escape', keyCode: 27, charCode: 27 },
1515
Backspace: { key: 'Backspace', keyCode: 8 },
1616

1717
ArrowUp: { key: 'ArrowUp', keyCode: 38 },
@@ -23,7 +23,7 @@ export const Keys: Record<string, Partial<KeyboardEvent>> = {
2323
PageUp: { key: 'PageUp', keyCode: 33 },
2424
PageDown: { key: 'PageDown', keyCode: 34 },
2525

26-
Tab: { key: 'Tab', keyCode: 9 },
26+
Tab: { key: 'Tab', keyCode: 9, charCode: 9 },
2727
}
2828

2929
export function shift(event: Partial<KeyboardEvent>) {
@@ -34,30 +34,125 @@ export function word(input: string): Partial<KeyboardEvent>[] {
3434
return input.split('').map(key => ({ key }))
3535
}
3636

37-
export async function type(events: Partial<KeyboardEvent>[]) {
38-
jest.useFakeTimers()
39-
40-
try {
41-
if (document.activeElement === null) return expect(document.activeElement).not.toBe(null)
37+
let Default = Symbol()
38+
let Ignore = Symbol()
39+
40+
let cancellations: Record<string | typeof Default, Record<string, Set<string>>> = {
41+
[Default]: {
42+
keydown: new Set(['keypress']),
43+
keypress: new Set([]),
44+
keyup: new Set([]),
45+
},
46+
[Keys.Enter.key!]: {
47+
keydown: new Set(['keypress', 'click']),
48+
keypress: new Set(['click']),
49+
keyup: new Set([]),
50+
},
51+
[Keys.Space.key!]: {
52+
keydown: new Set(['keypress', 'click']),
53+
keypress: new Set([]),
54+
keyup: new Set(['click']),
55+
},
56+
[Keys.Tab.key!]: {
57+
keydown: new Set(['keypress', 'blur', 'focus']),
58+
keypress: new Set([]),
59+
keyup: new Set([]),
60+
},
61+
}
4262

43-
let element = document.activeElement
63+
let order: Record<
64+
string | typeof Default,
65+
((
66+
element: Element,
67+
event: Partial<KeyboardEvent | MouseEvent>
68+
) => boolean | typeof Ignore | Element)[]
69+
> = {
70+
[Default]: [
71+
function keydown(element, event) {
72+
return fireEvent.keyDown(element, event)
73+
},
74+
function keypress(element, event) {
75+
return fireEvent.keyPress(element, event)
76+
},
77+
function keyup(element, event) {
78+
return fireEvent.keyUp(element, event)
79+
},
80+
],
81+
[Keys.Enter.key!]: [
82+
function keydown(element, event) {
83+
return fireEvent.keyDown(element, event)
84+
},
85+
function keypress(element, event) {
86+
return fireEvent.keyPress(element, event)
87+
},
88+
function click(element, event) {
89+
if (element instanceof HTMLButtonElement) return fireEvent.click(element, event)
90+
return Ignore
91+
},
92+
function keyup(element, event) {
93+
return fireEvent.keyUp(element, event)
94+
},
95+
],
96+
[Keys.Space.key!]: [
97+
function keydown(element, event) {
98+
return fireEvent.keyDown(element, event)
99+
},
100+
function keypress(element, event) {
101+
return fireEvent.keyPress(element, event)
102+
},
103+
function keyup(element, event) {
104+
return fireEvent.keyUp(element, event)
105+
},
106+
function click(element, event) {
107+
if (element instanceof HTMLButtonElement) return fireEvent.click(element, event)
108+
return Ignore
109+
},
110+
],
111+
[Keys.Tab.key!]: [
112+
function keydown(element, event) {
113+
return fireEvent.keyDown(element, event)
114+
},
115+
function blurAndfocus(_element, event) {
116+
return focusNext(event)
117+
},
118+
function keyup(element, event) {
119+
return fireEvent.keyUp(element, event)
120+
},
121+
],
122+
}
44123

45-
events.forEach(event => {
46-
const cancelled1 = !fireEvent.keyDown(element, event)
124+
export async function type(events: Partial<KeyboardEvent>[], element = document.activeElement) {
125+
jest.useFakeTimers()
47126

48-
// Special treatment for `Tab` on an element
49-
if (!cancelled1 && event.key === Keys.Tab.key) {
50-
element = focusNext(event)
51-
}
127+
try {
128+
if (element === null) return expect(element).not.toBe(null)
52129

53-
const cancelled2 = !fireEvent.keyPress(element, event)
54-
// Special treatment for `Enter` on a button element
55-
if (!cancelled2 && event.key === Keys.Enter.key && element instanceof HTMLButtonElement) {
56-
fireEvent.click(element)
130+
for (let event of events) {
131+
let skip = new Set()
132+
let actions = order[event.key!] ?? order[Default as any]
133+
for (let action of actions) {
134+
let checks = action.name.split('And')
135+
if (checks.some(check => skip.has(check))) continue
136+
137+
let result = action(element, {
138+
type: action.name,
139+
charCode: event.key?.length === 1 ? event.key?.charCodeAt(0) : undefined,
140+
...event,
141+
})
142+
if (result === Ignore) continue
143+
if (result instanceof Element) {
144+
element = result
145+
}
146+
147+
let cancelled = !result
148+
if (cancelled) {
149+
let skippablesForKey = cancellations[event.key!] ?? cancellations[Default as any]
150+
let skippables = skippablesForKey?.[action.name] ?? new Set()
151+
152+
for (let skippable of skippables) skip.add(skippable)
153+
}
57154
}
58-
59-
fireEvent.keyUp(element, event)
60-
})
155+
}
61156

62157
// We don't want to actually wait in our tests, so let's advance
63158
jest.runAllTimers()

packages/@headlessui-react/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"noUnusedParameters": true,
1313
"noImplicitReturns": true,
1414
"noFallthroughCasesInSwitch": true,
15+
"downlevelIteration": true,
1516
"moduleResolution": "node",
1617
"baseUrl": "./",
1718
"paths": {

0 commit comments

Comments
 (0)