Skip to content

Commit

Permalink
feat(keyboard): add [Tab] support (#767)
Browse files Browse the repository at this point in the history
  • Loading branch information
ph-fritsche committed Nov 28, 2021
1 parent 8f203cc commit 87470ff
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 122 deletions.
1 change: 1 addition & 0 deletions src/keyboard/keyMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
22 changes: 22 additions & 0 deletions src/keyboard/plugins/functional.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
*/

import {fireEvent} from '@testing-library/dom'
import {setUISelection} from '../../document'
import {
blur,
calculateNewValue,
fireInputEvent,
focus,
getTabDestination,
hasFormSubmit,
isClickableInput,
isCursorAtStart,
Expand Down Expand Up @@ -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[] = [
Expand Down
135 changes: 13 additions & 122 deletions src/tab.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, HTMLInputElement> = {}
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',
Expand All @@ -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)
}
Expand All @@ -162,9 +59,3 @@ export function tab(
fireEvent.keyUp(keyUpTarget, {...shiftKeyInit, shiftKey: false})
}
}

/*
eslint
complexity: "off",
max-statements: "off",
*/
77 changes: 77 additions & 0 deletions src/utils/focus/getTabDestination.ts
Original file line number Diff line number Diff line change
@@ -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<string, HTMLInputElement> = {}
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]
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
37 changes: 37 additions & 0 deletions tests/keyboard/plugin/functional.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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]
>(`<input value="abc"/><input type="number" value="1e5"/><button/>`)

userEvent.keyboard('[Tab]')

expect(elements[0]).toHaveFocus()
expect(elements[0]).toHaveProperty('selectionStart', 0)
expect(elements[0]).toHaveProperty('selectionEnd', 3)

userEvent.keyboard('[Tab]')

expect(elements[1]).toHaveFocus()
expect(getUISelection(elements[1])).toHaveProperty('selectionStart', 0)
expect(getUISelection(elements[1])).toHaveProperty('selectionEnd', 3)

userEvent.keyboard('[Tab]')

expect(elements[2]).toHaveFocus()

userEvent.keyboard('[Tab]')

expect(document.body).toHaveFocus()

userEvent.keyboard('[ShiftLeft>][Tab]')

expect(elements[2]).toHaveFocus()

userEvent.keyboard('[ShiftRight>][Tab]')

expect(elements[1]).toHaveFocus()
expect(getUISelection(elements[1])).toHaveProperty('selectionStart', 0)
expect(getUISelection(elements[1])).toHaveProperty('selectionEnd', 3)
})

0 comments on commit 87470ff

Please sign in to comment.