From 4459689beb8d01a8f7ab153a00e3480d2873b3fd Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 27 Jan 2021 00:32:45 +0100 Subject: [PATCH] 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 --- .../src/test-utils/interactions.test.tsx | 190 +++++++++++++++ .../src/test-utils/interactions.ts | 137 +++++++++-- packages/@headlessui-react/tsconfig.json | 1 + .../src/test-utils/interactions.test.ts | 217 ++++++++++++++++++ .../src/test-utils/interactions.ts | 137 +++++++++-- packages/@headlessui-vue/tsconfig.json | 1 + 6 files changed, 641 insertions(+), 42 deletions(-) create mode 100644 packages/@headlessui-react/src/test-utils/interactions.test.tsx create mode 100644 packages/@headlessui-vue/src/test-utils/interactions.test.ts diff --git a/packages/@headlessui-react/src/test-utils/interactions.test.tsx b/packages/@headlessui-react/src/test-utils/interactions.test.tsx new file mode 100644 index 0000000..0e6925f --- /dev/null +++ b/packages/@headlessui-react/src/test-utils/interactions.test.tsx @@ -0,0 +1,190 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { type, shift, Keys } from './interactions' + +type Events = 'onKeyDown' | 'onKeyUp' | 'onKeyPress' | 'onClick' | 'onBlur' | 'onFocus' +let events: Events[] = ['onKeyDown', 'onKeyUp', 'onKeyPress', 'onClick', 'onBlur', 'onFocus'] + +type Args = [ + string | Partial, + (string | Partial)[], + Set +] + +function key(input: string | Partial): Partial { + if (typeof input === 'string') return { key: input } + return input +} + +function event( + input: string | Partial, + target?: string +): Partial { + let e = typeof input === 'string' ? { type: input } : input + + if (target) { + Object.defineProperty(e, 'target', { + configurable: false, + enumerable: true, + get() { + return document.getElementById(target!) + }, + }) + } + + return e +} + +describe('Keyboard', () => { + describe('type', () => { + it.each([ + // Default - no cancellation + ['a', ['keydown', 'keypress', 'keyup'], new Set()], + [Keys.Space, ['keydown', 'keypress', 'keyup', 'click'], new Set()], + [Keys.Enter, ['keydown', 'keypress', 'click', 'keyup'], new Set()], + [ + Keys.Tab, + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'after'), + event('keyup', 'after'), + ], + new Set(), + ], + [ + shift(Keys.Tab), + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'before'), + event('keyup', 'before'), + ], + new Set(), + ], + + // Canceling keydown + ['a', ['keydown', 'keyup'], new Set(['onKeyDown'])], + [Keys.Space, ['keydown', 'keyup'], new Set(['onKeyDown'])], + [Keys.Enter, ['keydown', 'keyup'], new Set(['onKeyDown'])], + [Keys.Tab, ['keydown', 'keyup'], new Set(['onKeyDown'])], + [shift(Keys.Tab), ['keydown', 'keyup'], new Set(['onKeyDown'])], + + // Canceling keypress + ['a', ['keydown', 'keypress', 'keyup'], new Set(['onKeyPress'])], + [Keys.Space, ['keydown', 'keypress', 'keyup', 'click'], new Set(['onKeyPress'])], + [Keys.Enter, ['keydown', 'keypress', 'keyup'], new Set(['onKeyPress'])], + [ + Keys.Tab, + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'after'), + event('keyup', 'after'), + ], + new Set(['onKeyPress']), + ], + [ + shift(Keys.Tab), + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'before'), + event('keyup', 'before'), + ], + new Set(['onKeyPress']), + ], + + // Canceling keyup + ['a', ['keydown', 'keypress', 'keyup'], new Set(['onKeyUp'])], + [Keys.Space, ['keydown', 'keypress', 'keyup'], new Set(['onKeyUp'])], + [Keys.Enter, ['keydown', 'keypress', 'click', 'keyup'], new Set(['onKeyUp'])], + [ + Keys.Tab, + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'after'), + event('keyup', 'after'), + ], + new Set(['onKeyUp']), + ], + [ + shift(Keys.Tab), + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'before'), + event('keyup', 'before'), + ], + new Set(['onKeyUp']), + ], + + // Cancelling blur + [ + Keys.Tab, + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'after'), + event('keyup', 'after'), + ], + new Set(['onBlur']), + ], + [ + shift(Keys.Tab), + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'before'), + event('keyup', 'before'), + ], + new Set(['onBlur']), + ], + ])('should fire the correct events %#', async (input, result, prevents) => { + let fired: (KeyboardEvent | MouseEvent)[] = [] + + let state = { readyToCapture: false } + + function createProps(id: string) { + return events.reduce( + (props: React.ComponentProps<'button'>, name) => { + props[name] = (event: any) => { + if (!state.readyToCapture) return + if (prevents.has(name)) event.preventDefault() + fired.push(event.nativeEvent) + } + return props + }, + { id } + ) + } + + render( + <> + + + + + ) + + let trigger = document.getElementById('trigger') + trigger?.focus() + state.readyToCapture = true + + await type([key(input)]) + + let expected = result.map(e => event(e)) + + expect(fired.length).toEqual(result.length) + + for (let [idx, event] of fired.entries()) { + for (let key in expected[idx]) { + let _key = key as keyof (KeyboardEvent | MouseEvent) + expect(event[_key]).toBe(expected[idx][_key]) + } + } + }) + }) +}) diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index 1fd0c30..d1472be 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/packages/@headlessui-react/src/test-utils/interactions.ts @@ -9,9 +9,9 @@ function nextFrame(cb: Function): void { } export const Keys: Record> = { - Space: { key: ' ', keyCode: 32 }, - Enter: { key: 'Enter', keyCode: 13 }, - Escape: { key: 'Escape', keyCode: 27 }, + Space: { key: ' ', keyCode: 32, charCode: 32 }, + Enter: { key: 'Enter', keyCode: 13, charCode: 13 }, + Escape: { key: 'Escape', keyCode: 27, charCode: 27 }, Backspace: { key: 'Backspace', keyCode: 8 }, ArrowUp: { key: 'ArrowUp', keyCode: 38 }, @@ -23,7 +23,7 @@ export const Keys: Record> = { PageUp: { key: 'PageUp', keyCode: 33 }, PageDown: { key: 'PageDown', keyCode: 34 }, - Tab: { key: 'Tab', keyCode: 9 }, + Tab: { key: 'Tab', keyCode: 9, charCode: 9 }, } export function shift(event: Partial) { @@ -34,30 +34,125 @@ export function word(input: string): Partial[] { return input.split('').map(key => ({ key })) } -export async function type(events: Partial[]) { +let Default = Symbol() +let Ignore = Symbol() + +let cancellations: Record>> = { + [Default]: { + keydown: new Set(['keypress']), + keypress: new Set([]), + keyup: new Set([]), + }, + [Keys.Enter.key!]: { + keydown: new Set(['keypress', 'click']), + keypress: new Set(['click']), + keyup: new Set([]), + }, + [Keys.Space.key!]: { + keydown: new Set(['keypress', 'click']), + keypress: new Set([]), + keyup: new Set(['click']), + }, + [Keys.Tab.key!]: { + keydown: new Set(['keypress', 'blur', 'focus']), + keypress: new Set([]), + keyup: new Set([]), + }, +} + +let order: Record< + string | typeof Default, + (( + element: Element, + event: Partial + ) => boolean | typeof Ignore | Element)[] +> = { + [Default]: [ + function keydown(element, event) { + return fireEvent.keyDown(element, event) + }, + function keypress(element, event) { + return fireEvent.keyPress(element, event) + }, + function keyup(element, event) { + return fireEvent.keyUp(element, event) + }, + ], + [Keys.Enter.key!]: [ + function keydown(element, event) { + return fireEvent.keyDown(element, event) + }, + function keypress(element, event) { + return fireEvent.keyPress(element, event) + }, + function click(element, event) { + if (element instanceof HTMLButtonElement) return fireEvent.click(element, event) + return Ignore + }, + function keyup(element, event) { + return fireEvent.keyUp(element, event) + }, + ], + [Keys.Space.key!]: [ + function keydown(element, event) { + return fireEvent.keyDown(element, event) + }, + function keypress(element, event) { + return fireEvent.keyPress(element, event) + }, + function keyup(element, event) { + return fireEvent.keyUp(element, event) + }, + function click(element, event) { + if (element instanceof HTMLButtonElement) return fireEvent.click(element, event) + return Ignore + }, + ], + [Keys.Tab.key!]: [ + function keydown(element, event) { + return fireEvent.keyDown(element, event) + }, + function blurAndfocus(_element, event) { + return focusNext(event) + }, + function keyup(element, event) { + return fireEvent.keyUp(element, event) + }, + ], +} + +export async function type(events: Partial[], element = document.activeElement) { jest.useFakeTimers() try { - if (document.activeElement === null) return expect(document.activeElement).not.toBe(null) + if (element === null) return expect(element).not.toBe(null) - let element = document.activeElement + for (let event of events) { + let skip = new Set() + let actions = order[event.key!] ?? order[Default as any] + for (let action of actions) { + let checks = action.name.split('And') + if (checks.some(check => skip.has(check))) continue - events.forEach(event => { - const cancelled1 = !fireEvent.keyDown(element, event) + let result = action(element, { + type: action.name, + charCode: event.key?.length === 1 ? event.key?.charCodeAt(0) : undefined, + ...event, + }) + if (result === Ignore) continue + if (result instanceof Element) { + element = result + } - // Special treatment for `Tab` on an element - if (!cancelled1 && event.key === Keys.Tab.key) { - element = focusNext(event) + let cancelled = !result + if (cancelled) { + let skippablesForKey = cancellations[event.key!] ?? cancellations[Default as any] + let skippables = skippablesForKey?.[action.name] ?? new Set() + + for (let skippable of skippables) skip.add(skippable) + } } - - const cancelled2 = !fireEvent.keyPress(element, event) - // Special treatment for `Enter` on a button element - if (!cancelled2 && event.key === Keys.Enter.key && element instanceof HTMLButtonElement) { - fireEvent.click(element) - } - - fireEvent.keyUp(element, event) - }) + } // We don't want to actually wait in our tests, so let's advance jest.runAllTimers() diff --git a/packages/@headlessui-react/tsconfig.json b/packages/@headlessui-react/tsconfig.json index 1ebc31d..cac92c1 100644 --- a/packages/@headlessui-react/tsconfig.json +++ b/packages/@headlessui-react/tsconfig.json @@ -12,6 +12,7 @@ "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, + "downlevelIteration": true, "moduleResolution": "node", "baseUrl": "./", "paths": { diff --git a/packages/@headlessui-vue/src/test-utils/interactions.test.ts b/packages/@headlessui-vue/src/test-utils/interactions.test.ts new file mode 100644 index 0000000..81c3594 --- /dev/null +++ b/packages/@headlessui-vue/src/test-utils/interactions.test.ts @@ -0,0 +1,217 @@ +import { render } from './vue-testing-library' + +import { type, shift, Keys } from './interactions' +import { defineComponent, h } from 'vue' + +type Events = 'onKeyDown' | 'onKeyUp' | 'onKeyPress' | 'onClick' | 'onBlur' | 'onFocus' +let events: Events[] = ['onKeyDown', 'onKeyUp', 'onKeyPress', 'onClick', 'onBlur', 'onFocus'] + +function renderTemplate(input: string | Partial[0]>) { + const defaultComponents = {} + + if (typeof input === 'string') { + return render(defineComponent({ template: input, components: defaultComponents })) + } + + return render( + defineComponent( + Object.assign({}, input, { + components: { ...defaultComponents, ...input.components }, + }) as Parameters[0] + ) + ) +} + +type Args = [ + string | Partial, + (string | Partial)[], + Set +] + +function key(input: string | Partial): Partial { + if (typeof input === 'string') return { key: input } + return input +} + +function event( + input: string | Partial, + target?: string +): Partial { + let e = typeof input === 'string' ? { type: input } : input + + if (target) { + Object.defineProperty(e, 'target', { + configurable: false, + enumerable: true, + get() { + return document.getElementById(target!) + }, + }) + } + + return e +} + +describe('Keyboard', () => { + describe('type', () => { + it.each([ + // Default - no cancellation + ['a', ['keydown', 'keypress', 'keyup'], new Set()], + [Keys.Space, ['keydown', 'keypress', 'keyup', 'click'], new Set()], + [Keys.Enter, ['keydown', 'keypress', 'click', 'keyup'], new Set()], + [ + Keys.Tab, + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'after'), + event('keyup', 'after'), + ], + new Set(), + ], + [ + shift(Keys.Tab), + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'before'), + event('keyup', 'before'), + ], + new Set(), + ], + + // Canceling keydown + ['a', ['keydown', 'keyup'], new Set(['onKeyDown'])], + [Keys.Space, ['keydown', 'keyup'], new Set(['onKeyDown'])], + [Keys.Enter, ['keydown', 'keyup'], new Set(['onKeyDown'])], + [Keys.Tab, ['keydown', 'keyup'], new Set(['onKeyDown'])], + [shift(Keys.Tab), ['keydown', 'keyup'], new Set(['onKeyDown'])], + + // Canceling keypress + ['a', ['keydown', 'keypress', 'keyup'], new Set(['onKeyPress'])], + [Keys.Space, ['keydown', 'keypress', 'keyup', 'click'], new Set(['onKeyPress'])], + [Keys.Enter, ['keydown', 'keypress', 'keyup'], new Set(['onKeyPress'])], + [ + Keys.Tab, + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'after'), + event('keyup', 'after'), + ], + new Set(['onKeyPress']), + ], + [ + shift(Keys.Tab), + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'before'), + event('keyup', 'before'), + ], + new Set(['onKeyPress']), + ], + + // Canceling keyup + ['a', ['keydown', 'keypress', 'keyup'], new Set(['onKeyUp'])], + [Keys.Space, ['keydown', 'keypress', 'keyup'], new Set(['onKeyUp'])], + [Keys.Enter, ['keydown', 'keypress', 'click', 'keyup'], new Set(['onKeyUp'])], + [ + Keys.Tab, + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'after'), + event('keyup', 'after'), + ], + new Set(['onKeyUp']), + ], + [ + shift(Keys.Tab), + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'before'), + event('keyup', 'before'), + ], + new Set(['onKeyUp']), + ], + + // Cancelling blur + [ + Keys.Tab, + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'after'), + event('keyup', 'after'), + ], + new Set(['onBlur']), + ], + [ + shift(Keys.Tab), + [ + event('keydown', 'trigger'), + event('blur', 'trigger'), + event('focus', 'before'), + event('keyup', 'before'), + ], + new Set(['onBlur']), + ], + ])('should fire the correct events %#', async (input, result, prevents) => { + let fired: (KeyboardEvent | MouseEvent)[] = [] + + let state = { readyToCapture: false } + + function createProps(id: string) { + return events.reduce( + (props, name) => { + props[name] = (event: any) => { + if (!state.readyToCapture) return + if (prevents.has(name)) event.preventDefault() + fired.push(event) + } + return props + }, + { id } + ) + } + + renderTemplate({ + template: ` +
+ + + +
+ `, + components: { + Button: defineComponent({ + setup(_props, { slots, attrs }) { + return () => { + return h('button', createProps(attrs.id as string), slots.default()) + } + }, + }), + }, + }) + + let trigger = document.getElementById('trigger') + trigger?.focus() + state.readyToCapture = true + + await type([key(input)]) + + let expected = result.map(e => event(e)) + + expect(fired.length).toEqual(result.length) + + for (let [idx, event] of fired.entries()) { + for (let key in expected[idx]) { + let _key = key as keyof (KeyboardEvent | MouseEvent) + expect(event[_key]).toBe(expected[idx][_key]) + } + } + }) + }) +}) diff --git a/packages/@headlessui-vue/src/test-utils/interactions.ts b/packages/@headlessui-vue/src/test-utils/interactions.ts index f449d35..dd037e5 100644 --- a/packages/@headlessui-vue/src/test-utils/interactions.ts +++ b/packages/@headlessui-vue/src/test-utils/interactions.ts @@ -9,9 +9,9 @@ function nextFrame(cb: Function): void { } export const Keys: Record> = { - Space: { key: ' ', keyCode: 32 }, - Enter: { key: 'Enter', keyCode: 13 }, - Escape: { key: 'Escape', keyCode: 27 }, + Space: { key: ' ', keyCode: 32, charCode: 32 }, + Enter: { key: 'Enter', keyCode: 13, charCode: 13 }, + Escape: { key: 'Escape', keyCode: 27, charCode: 27 }, Backspace: { key: 'Backspace', keyCode: 8 }, ArrowUp: { key: 'ArrowUp', keyCode: 38 }, @@ -23,7 +23,7 @@ export const Keys: Record> = { PageUp: { key: 'PageUp', keyCode: 33 }, PageDown: { key: 'PageDown', keyCode: 34 }, - Tab: { key: 'Tab', keyCode: 9 }, + Tab: { key: 'Tab', keyCode: 9, charCode: 9 }, } export function shift(event: Partial) { @@ -34,30 +34,125 @@ export function word(input: string): Partial[] { return input.split('').map(key => ({ key })) } -export async function type(events: Partial[]) { +let Default = Symbol() +let Ignore = Symbol() + +let cancellations: Record>> = { + [Default]: { + keydown: new Set(['keypress']), + keypress: new Set([]), + keyup: new Set([]), + }, + [Keys.Enter.key!]: { + keydown: new Set(['keypress', 'click']), + keypress: new Set(['click']), + keyup: new Set([]), + }, + [Keys.Space.key!]: { + keydown: new Set(['keypress', 'click']), + keypress: new Set([]), + keyup: new Set(['click']), + }, + [Keys.Tab.key!]: { + keydown: new Set(['keypress', 'blur', 'focus']), + keypress: new Set([]), + keyup: new Set([]), + }, +} + +let order: Record< + string | typeof Default, + (( + element: Element, + event: Partial + ) => boolean | typeof Ignore | Element)[] +> = { + [Default]: [ + function keydown(element, event) { + return fireEvent.keyDown(element, event) + }, + function keypress(element, event) { + return fireEvent.keyPress(element, event) + }, + function keyup(element, event) { + return fireEvent.keyUp(element, event) + }, + ], + [Keys.Enter.key!]: [ + function keydown(element, event) { + return fireEvent.keyDown(element, event) + }, + function keypress(element, event) { + return fireEvent.keyPress(element, event) + }, + function click(element, event) { + if (element instanceof HTMLButtonElement) return fireEvent.click(element, event) + return Ignore + }, + function keyup(element, event) { + return fireEvent.keyUp(element, event) + }, + ], + [Keys.Space.key!]: [ + function keydown(element, event) { + return fireEvent.keyDown(element, event) + }, + function keypress(element, event) { + return fireEvent.keyPress(element, event) + }, + function keyup(element, event) { + return fireEvent.keyUp(element, event) + }, + function click(element, event) { + if (element instanceof HTMLButtonElement) return fireEvent.click(element, event) + return Ignore + }, + ], + [Keys.Tab.key!]: [ + function keydown(element, event) { + return fireEvent.keyDown(element, event) + }, + function blurAndfocus(_element, event) { + return focusNext(event) + }, + function keyup(element, event) { + return fireEvent.keyUp(element, event) + }, + ], +} + +export async function type(events: Partial[], element = document.activeElement) { jest.useFakeTimers() try { - if (document.activeElement === null) return expect(document.activeElement).not.toBe(null) + if (element === null) return expect(element).not.toBe(null) - let element = document.activeElement + for (let event of events) { + let skip = new Set() + let actions = order[event.key!] ?? order[Default as any] + for (let action of actions) { + let checks = action.name.split('And') + if (checks.some(check => skip.has(check))) continue - events.forEach(event => { - const cancelled1 = !fireEvent.keyDown(element, event) + let result = action(element, { + type: action.name, + charCode: event.key?.length === 1 ? event.key?.charCodeAt(0) : undefined, + ...event, + }) + if (result === Ignore) continue + if (result instanceof Element) { + element = result + } - // Special treatment for `Tab` on an element - if (!cancelled1 && event.key === Keys.Tab.key) { - element = focusNext(event) + let cancelled = !result + if (cancelled) { + let skippablesForKey = cancellations[event.key!] ?? cancellations[Default as any] + let skippables = skippablesForKey?.[action.name] ?? new Set() + + for (let skippable of skippables) skip.add(skippable) + } } - - const cancelled2 = !fireEvent.keyPress(element, event) - // Special treatment for `Enter` on a button element - if (!cancelled2 && event.key === Keys.Enter.key && element instanceof HTMLButtonElement) { - fireEvent.click(element) - } - - fireEvent.keyUp(element, event) - }) + } // We don't want to actually wait in our tests, so let's advance jest.runAllTimers() diff --git a/packages/@headlessui-vue/tsconfig.json b/packages/@headlessui-vue/tsconfig.json index fd66287..a62faa1 100644 --- a/packages/@headlessui-vue/tsconfig.json +++ b/packages/@headlessui-vue/tsconfig.json @@ -12,6 +12,7 @@ "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, + "downlevelIteration": true, "moduleResolution": "node", "baseUrl": "./", "paths": {