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": {