From 87470ffe49c93c0b7a858d5c07d660fede90f881 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Fri, 29 Oct 2021 09:44:04 +0200 Subject: [PATCH] feat(keyboard): add `[Tab]` support (#767) --- src/keyboard/keyMap.ts | 1 + src/keyboard/plugins/functional.ts | 22 +++++ src/tab.ts | 135 +++------------------------ src/utils/focus/getTabDestination.ts | 77 +++++++++++++++ src/utils/index.ts | 1 + tests/keyboard/plugin/functional.ts | 37 ++++++++ 6 files changed, 151 insertions(+), 122 deletions(-) create mode 100644 src/utils/focus/getTabDestination.ts diff --git a/src/keyboard/keyMap.ts b/src/keyboard/keyMap.ts index 8af5e7f4..325f28cc 100644 --- a/src/keyboard/keyMap.ts +++ b/src/keyboard/keyMap.ts @@ -56,6 +56,7 @@ export const defaultKeyMap: keyboardKey[] = [ {code: 'OSLeft', key: 'OS', location: DOM_KEY_LOCATION.LEFT, keyCode: 91}, {code: 'OSRight', key: 'OS', location: DOM_KEY_LOCATION.RIGHT, keyCode: 91}, + {code: 'Tab', key: 'Tab', keyCode: 9}, {code: 'CapsLock', key: 'CapsLock', keyCode: 20}, {code: 'Backspace', key: 'Backspace', keyCode: 8}, {code: 'Enter', key: 'Enter', keyCode: 13}, diff --git a/src/keyboard/plugins/functional.ts b/src/keyboard/plugins/functional.ts index 80c2511e..10079663 100644 --- a/src/keyboard/plugins/functional.ts +++ b/src/keyboard/plugins/functional.ts @@ -4,9 +4,13 @@ */ import {fireEvent} from '@testing-library/dom' +import {setUISelection} from '../../document' import { + blur, calculateNewValue, fireInputEvent, + focus, + getTabDestination, hasFormSubmit, isClickableInput, isCursorAtStart, @@ -77,6 +81,24 @@ export const keydownBehavior: behaviorPlugin[] = [ }) }, }, + { + matches: keyDef => keyDef.key === 'Tab', + handle: (keyDef, element, options, state) => { + const dest = getTabDestination( + element, + state.modifiers.shift, + element.ownerDocument, + ) + if (dest === element.ownerDocument.body) { + blur(element) + } else { + focus(dest) + if (isElementType(dest, ['input', 'textarea'])) { + setUISelection(dest, 0, dest.value.length) + } + } + }, + }, ] export const keypressBehavior: behaviorPlugin[] = [ diff --git a/src/tab.ts b/src/tab.ts index fad1d0a0..6da2add2 100644 --- a/src/tab.ts +++ b/src/tab.ts @@ -1,34 +1,7 @@ import {fireEvent} from '@testing-library/dom' -import { - blur, - focus, - getActiveElement, - FOCUSABLE_SELECTOR, - isVisible, - isDisabled, - isDocument, -} from './utils' +import {blur, focus, getActiveElement, getTabDestination} from './utils' import type {UserEvent} from './setup' -function getNextElement( - currentIndex: number, - shift: boolean, - elements: Element[], - focusTrap: Document | Element, -) { - if ( - isDocument(focusTrap) && - ((currentIndex === 0 && shift) || - (currentIndex === elements.length - 1 && !shift)) - ) { - return focusTrap.body - } - - const nextIndex = shift ? currentIndex - 1 : currentIndex + 1 - const defaultIndex = shift ? elements.length - 1 : 0 - return elements[nextIndex] || elements[defaultIndex] -} - export interface tabOptions { shift?: boolean focusTrap?: Document | Element @@ -41,85 +14,18 @@ export function tab( const doc = focusTrap?.ownerDocument ?? document const previousElement = getActiveElement(doc) + // Never the case in our test environment + // Only happens if the activeElement is inside a shadowRoot that is not part of `doc`. + /* istanbul ignore next */ + if (!previousElement) { + return + } + if (!focusTrap) { focusTrap = doc } - const focusableElements = focusTrap.querySelectorAll(FOCUSABLE_SELECTOR) - - const enabledElements = Array.from(focusableElements).filter( - el => - el === previousElement || - (el.getAttribute('tabindex') !== '-1' && - !isDisabled(el) && - // Hidden elements are not tabable - isVisible(el)), - ) - - if (enabledElements.length === 0) return - - const orderedElements = enabledElements - .map((el, idx) => ({el, idx})) - .sort((a, b) => { - // tabindex has no effect if the active element has tabindex="-1" - if ( - previousElement && - previousElement.getAttribute('tabindex') === '-1' - ) { - return a.idx - b.idx - } - - const tabIndexA = Number(a.el.getAttribute('tabindex')) - const tabIndexB = Number(b.el.getAttribute('tabindex')) - - const diff = tabIndexA - tabIndexB - - return diff === 0 ? a.idx - b.idx : diff - }) - .map(({el}) => el) - - // TODO: verify/remove type casts - - const checkedRadio: Record = {} - let prunedElements: HTMLInputElement[] = [] - orderedElements.forEach(currentElement => { - // For radio groups keep only the active radio - // If there is no active radio, keep only the checked radio - // If there is no checked radio, treat like everything else - - const el = currentElement as HTMLInputElement - - if (el.type === 'radio' && el.name) { - // If the active element is part of the group, add only that - const prev = previousElement as HTMLInputElement | null - if (prev && prev.type === el.type && prev.name === el.name) { - if (el === prev) { - prunedElements.push(el) - } - return - } - - // If we stumble upon a checked radio, remove the others - if (el.checked) { - prunedElements = prunedElements.filter( - e => e.type !== el.type || e.name !== el.name, - ) - prunedElements.push(el) - checkedRadio[el.name] = el - return - } - - // If we already found the checked one, skip - if (typeof checkedRadio[el.name] !== 'undefined') { - return - } - } - - prunedElements.push(el) - }) - - const index = prunedElements.findIndex(el => el === previousElement) - const nextElement = getNextElement(index, shift, prunedElements, focusTrap) + const nextElement = getTabDestination(previousElement, shift, focusTrap) const shiftKeyInit = { key: 'Shift', @@ -134,23 +40,14 @@ export function tab( let continueToTab = true - // not sure how to make it so there's no previous element... - // istanbul ignore else - if (previousElement) { - // preventDefault on the shift key makes no difference - if (shift) fireEvent.keyDown(previousElement, {...shiftKeyInit}) - continueToTab = fireEvent.keyDown(previousElement, {...tabKeyInit}) - } + if (shift) fireEvent.keyDown(previousElement, {...shiftKeyInit}) + continueToTab = fireEvent.keyDown(previousElement, {...tabKeyInit}) - const keyUpTarget = - !continueToTab && previousElement ? previousElement : nextElement + const keyUpTarget = continueToTab ? nextElement : previousElement if (continueToTab) { if (nextElement === doc.body) { - /* istanbul ignore else */ - if (previousElement) { - blur(previousElement) - } + blur(previousElement) } else { focus(nextElement) } @@ -162,9 +59,3 @@ export function tab( fireEvent.keyUp(keyUpTarget, {...shiftKeyInit, shiftKey: false}) } } - -/* -eslint - complexity: "off", - max-statements: "off", -*/ diff --git a/src/utils/focus/getTabDestination.ts b/src/utils/focus/getTabDestination.ts new file mode 100644 index 00000000..02795c51 --- /dev/null +++ b/src/utils/focus/getTabDestination.ts @@ -0,0 +1,77 @@ +import {isDisabled} from '../misc/isDisabled' +import {isDocument} from '../misc/isDocument' +import {isElementType} from '../misc/isElementType' +import {isVisible} from '../misc/isVisible' +import {FOCUSABLE_SELECTOR} from './selector' + +export function getTabDestination( + activeElement: Element, + shift: boolean, + focusTrap: Document | Element, +) { + const focusableElements = focusTrap.querySelectorAll(FOCUSABLE_SELECTOR) + + const enabledElements = Array.from(focusableElements).filter( + el => + el === activeElement || + (el.getAttribute('tabindex') !== '-1' && + !isDisabled(el) && + // Hidden elements are not tabable + isVisible(el)), + ) + + if (activeElement.getAttribute('tabindex') !== '-1') { + // tabindex has no effect if the active element has tabindex="-1" + enabledElements.sort( + (a, b) => + Number(a.getAttribute('tabindex')) - Number(b.getAttribute('tabindex')), + ) + } + + const checkedRadio: Record = {} + let prunedElements: Element[] = isDocument(focusTrap) ? [focusTrap.body] : [] + const activeRadioGroup = isElementType(activeElement, 'input', { + type: 'radio', + }) + ? activeElement.name + : undefined + enabledElements.forEach(currentElement => { + const el = currentElement as HTMLInputElement + + // For radio groups keep only the active radio + // If there is no active radio, keep only the checked radio + // If there is no checked radio, treat like everything else + if (isElementType(el, 'input', {type: 'radio'}) && el.name) { + // If the active element is part of the group, add only that + if (el === activeElement) { + prunedElements.push(el) + return + } else if (el.name === activeRadioGroup) { + return + } + + // If we stumble upon a checked radio, remove the others + if (el.checked) { + prunedElements = prunedElements.filter( + e => !isElementType(e, 'input', {type: 'radio', name: el.name}), + ) + prunedElements.push(el) + checkedRadio[el.name] = el + return + } + + // If we already found the checked one, skip + if (typeof checkedRadio[el.name] !== 'undefined') { + return + } + } + + prunedElements.push(el) + }) + + const currentIndex = prunedElements.findIndex(el => el === activeElement) + + const nextIndex = shift ? currentIndex - 1 : currentIndex + 1 + const defaultIndex = shift ? prunedElements.length - 1 : 0 + return prunedElements[nextIndex] || prunedElements[defaultIndex] +} diff --git a/src/utils/index.ts b/src/utils/index.ts index e49571cc..b6016897 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,6 +15,7 @@ export * from './edit/selectionRange' export * from './focus/blur' export * from './focus/focus' export * from './focus/getActiveElement' +export * from './focus/getTabDestination' export * from './focus/isFocusable' export * from './focus/selector' diff --git a/tests/keyboard/plugin/functional.ts b/tests/keyboard/plugin/functional.ts index c47316c1..0a2ba929 100644 --- a/tests/keyboard/plugin/functional.ts +++ b/tests/keyboard/plugin/functional.ts @@ -1,4 +1,5 @@ import userEvent from '#src' +import {getUISelection} from '#src/document' import {setup} from '#testHelpers/utils' test('produce extra events for the Control key when AltGraph is pressed', () => { @@ -210,3 +211,39 @@ test('trigger change event on [Space] keyup on HTMLInputElement type=radio', () input[checked=true] - change `) }) + +test('tab through elements', () => { + const {elements} = setup< + [HTMLInputElement, HTMLInputElement, HTMLButtonElement] + >(`