Skip to content

Commit

Permalink
fix: handle selectionRange on different input types (#619)
Browse files Browse the repository at this point in the history
* wip: selectionRange

* test: remove testPathIgnorePattern for utils

* fix: refactor selection handling

* fix: {selectall} on number input
  • Loading branch information
ph-fritsche committed Mar 24, 2021
1 parent 7ff9a9a commit d5aa3ee
Show file tree
Hide file tree
Showing 18 changed files with 284 additions and 140 deletions.
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ const config = require('kcd-scripts/jest')
module.exports = {
...config,
testEnvironment: 'jest-environment-jsdom',

// this repo is testing utils
testPathIgnorePatterns: config.testPathIgnorePatterns.filter(f => f !== '/__tests__/utils/'),
}
8 changes: 8 additions & 0 deletions src/__tests__/type.js
Original file line number Diff line number Diff line change
Expand Up @@ -1416,3 +1416,11 @@ test('type non-alphanumeric characters', () => {

expect(element).toHaveValue('https://test.local')
})

test('use {selectall} on <input type="number"/>', () => {
const {element} = setup(`<input type="number" value="0"/>`)

userEvent.type(element, '123{selectall}{backspace}4')

expect(element).toHaveValue(4)
})
13 changes: 13 additions & 0 deletions src/__tests__/utils/edit/isContentEditable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {setup} from '__tests__/helpers/utils'
import {isContentEditable} from '../../../utils'

test('report if element is contenteditable', () => {
const {elements} = setup(
`<div></div><div contenteditable="false"></div><div contenteditable></div><div contenteditable="true"></div>`,
)

expect(isContentEditable(elements[0])).toBe(false)
expect(isContentEditable(elements[1])).toBe(false)
expect(isContentEditable(elements[2])).toBe(true)
expect(isContentEditable(elements[3])).toBe(true)
})
72 changes: 72 additions & 0 deletions src/__tests__/utils/edit/selectionRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {getSelectionRange, setSelectionRange} from 'utils'
import {setup} from '__tests__/helpers/utils'

test('range on input', () => {
const {element} = setup('<input value="foo"/>')

expect(getSelectionRange(element as HTMLInputElement)).toEqual({
selectionStart: 0,
selectionEnd: 0,
})

setSelectionRange(element as HTMLInputElement, 0, 0)

expect(element).toHaveProperty('selectionStart', 0)
expect(element).toHaveProperty('selectionEnd', 0)
expect(getSelectionRange(element as HTMLInputElement)).toEqual({
selectionStart: 0,
selectionEnd: 0,
})

setSelectionRange(element as HTMLInputElement, 2, 3)

expect(element).toHaveProperty('selectionStart', 2)
expect(element).toHaveProperty('selectionEnd', 3)
expect(getSelectionRange(element as HTMLInputElement)).toEqual({
selectionStart: 2,
selectionEnd: 3,
})
})

test('range on contenteditable', () => {
const {element} = setup('<div contenteditable="true">foo</div>')

expect(getSelectionRange(element as HTMLInputElement)).toEqual({
selectionStart: null,
selectionEnd: null,
})

setSelectionRange(element as HTMLDivElement, 0, 0)

expect(getSelectionRange(element as HTMLInputElement)).toEqual({
selectionStart: 0,
selectionEnd: 0,
})

setSelectionRange(element as HTMLDivElement, 2, 3)

expect(document.getSelection()?.anchorNode).toBe(element?.firstChild)
expect(document.getSelection()?.focusNode).toBe(element?.firstChild)
expect(document.getSelection()?.anchorOffset).toBe(2)
expect(document.getSelection()?.focusOffset).toBe(3)
expect(getSelectionRange(element as HTMLInputElement)).toEqual({
selectionStart: 2,
selectionEnd: 3,
})
})

test('range on input without selection support', () => {
const {element} = setup(`<input type="number" value="123"/>`)

expect(getSelectionRange(element as HTMLInputElement)).toEqual({
selectionStart: null,
selectionEnd: null,
})

setSelectionRange(element as HTMLInputElement, 1, 2)

expect(getSelectionRange(element as HTMLInputElement)).toEqual({
selectionStart: 1,
selectionEnd: 2,
})
})
4 changes: 2 additions & 2 deletions src/keyboard/plugins/arrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import {behaviorPlugin} from '../types'
import {isElementType, setSelectionRangeIfNecessary} from '../../utils'
import {isElementType, setSelectionRange} from '../../utils'

export const keydownBehavior: behaviorPlugin[] = [
{
Expand All @@ -24,7 +24,7 @@ export const keydownBehavior: behaviorPlugin[] = [
? selectionStart
: selectionEnd) ?? /* istanbul ignore next */ 0

setSelectionRangeIfNecessary(element, newPos, newPos)
setSelectionRange(element, newPos, newPos)
},
},
]
6 changes: 3 additions & 3 deletions src/keyboard/plugins/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
getValue,
isContentEditable,
isElementType,
setSelectionRangeIfNecessary,
setSelectionRange,
} from '../../utils'
import {fireInputEventIfNeeded} from '../shared'
import {calculateNewDeleteValue} from './control/calculateNewDeleteValue'
Expand All @@ -22,10 +22,10 @@ export const keydownBehavior: behaviorPlugin[] = [
handle: (keyDef, element) => {
// This could probably been improved by collapsing a selection range
if (keyDef.key === 'Home') {
setSelectionRangeIfNecessary(element, 0, 0)
setSelectionRange(element, 0, 0)
} else {
const newPos = getValue(element)?.length ?? /* istanbul ignore next */ 0
setSelectionRangeIfNecessary(element, newPos, newPos)
setSelectionRange(element, newPos, newPos)
}
},
},
Expand Down
13 changes: 10 additions & 3 deletions src/keyboard/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {behaviorPlugin} from '../types'
import {isElementType} from '../../utils'
import {isElementType, setSelectionRange} from '../../utils'
import * as arrowKeys from './arrow'
import * as controlKeys from './control'
import * as characterKeys from './character'
Expand All @@ -10,8 +10,15 @@ export const replaceBehavior: behaviorPlugin[] = [
matches: (keyDef, element) =>
keyDef.key === 'selectall' &&
isElementType(element, ['input', 'textarea']),
handle: (keyDef, element) => {
;(element as HTMLInputElement).select()
handle: (keyDef, element, options, state) => {
setSelectionRange(
element,
0,
(
state.carryValue ??
(element as HTMLInputElement | HTMLTextAreaElement).value
).length,
)
},
},
]
Expand Down
36 changes: 30 additions & 6 deletions src/keyboard/shared/fireInputEventIfNeeded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import {
isElementType,
isClickableInput,
getValue,
hasUnreliableEmptyValue,
isContentEditable,
setSelectionRange,
} from '../../utils'
import {setSelectionRange} from './setSelectionRange'

export function fireInputEventIfNeeded({
currentElement,
Expand Down Expand Up @@ -42,11 +43,7 @@ export function fireInputEventIfNeeded({
})
}

setSelectionRange({
currentElement,
newValue,
newSelectionStart,
})
setSelectionRangeAfterInput(el, newValue, newSelectionStart)
}

return {prevValue}
Expand All @@ -55,3 +52,30 @@ export function fireInputEventIfNeeded({
function isReadonly(element: Element): boolean {
return isElementType(element, ['input', 'textarea'], {readOnly: true})
}

function setSelectionRangeAfterInput(
element: Element,
newValue: string,
newSelectionStart: number,
) {
// if we *can* change the selection start, then we will if the new value
// is the same as the current value (so it wasn't programatically changed
// when the fireEvent.input was triggered).
// The reason we have to do this at all is because it actually *is*
// programmatically changed by fireEvent.input, so we have to simulate the
// browser's default behavior
const value = getValue(element) as string

// don't apply this workaround on elements that don't necessarily report the visible value - e.g. number
if (
value === newValue ||
(value === '' && hasUnreliableEmptyValue(element))
) {
setSelectionRange(element, newSelectionStart, newSelectionStart)
} else {
// If the currentValue is different than the expected newValue and we *can*
// change the selection range, than we should set it to the length of the
// currentValue to ensure that the browser behavior is mimicked.
setSelectionRange(element, value.length, value.length)
}
}
1 change: 0 additions & 1 deletion src/keyboard/shared/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export * from './fireChangeForInputTimeIfValid'
export * from './fireInputEventIfNeeded'
export * from './setSelectionRange'
30 changes: 0 additions & 30 deletions src/keyboard/shared/setSelectionRange.ts

This file was deleted.

6 changes: 3 additions & 3 deletions src/paste.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {fireEvent} from '@testing-library/dom'
import {
setSelectionRangeIfNecessary,
setSelectionRange,
calculateNewValue,
eventWrapper,
isDisabled,
Expand Down Expand Up @@ -38,7 +38,7 @@ function paste(
// initialSelectionEnd is if you have an input with a value and want to
// explicitely start typing with the cursor at 0. Not super common.
if (element.selectionStart === 0 && element.selectionEnd === 0) {
setSelectionRangeIfNecessary(
setSelectionRange(
element,
initialSelectionStart ?? element.value.length,
initialSelectionEnd ?? element.value.length,
Expand All @@ -53,7 +53,7 @@ function paste(
inputType: 'insertFromPaste',
target: {value: newValue},
})
setSelectionRangeIfNecessary(
setSelectionRange(
element,

// TODO: investigate why the selection caused by invalid parameters was expected
Expand Down
15 changes: 2 additions & 13 deletions src/type/typeImplementation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import {
setSelectionRangeIfNecessary,
setSelectionRange,
getSelectionRange,
getValue,
isContentEditable,
getActiveElement,
} from '../utils'
import {click} from '../click'
Expand Down Expand Up @@ -33,16 +32,6 @@ export async function typeImplementation(

if (!skipClick) click(element)

if (isContentEditable(element)) {
const selection = document.getSelection()
// istanbul ignore else
if (selection && selection.rangeCount === 0) {
const range = document.createRange()
range.setStart(element, 0)
range.setEnd(element, 0)
selection.addRange(range)
}
}
// The focused element could change between each event, so get the currently active element each time
const currentElement = () => getActiveElement(element.ownerDocument)

Expand All @@ -59,7 +48,7 @@ export async function typeImplementation(
const {selectionStart, selectionEnd} = getSelectionRange(element)

if (value != null && selectionStart === 0 && selectionEnd === 0) {
setSelectionRangeIfNecessary(
setSelectionRange(
currentElement() as Element,
initialSelectionStart ?? value.length,
initialSelectionEnd ?? value.length,
Expand Down
2 changes: 1 addition & 1 deletion src/utils/edit/calculateNewValue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {isElementType} from 'utils/misc/isElementType'
import {getSelectionRange} from './getSelectionRange'
import {getSelectionRange} from './selectionRange'
import {getValue} from './getValue'
import {isValidDateValue} from './isValidDateValue'
import {isValidInputTimeValue} from './isValidInputTimeValue'
Expand Down
30 changes: 0 additions & 30 deletions src/utils/edit/getSelectionRange.ts

This file was deleted.

21 changes: 21 additions & 0 deletions src/utils/edit/hasUnreliableEmptyValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {isElementType} from 'utils/misc/isElementType'

enum unreliableValueInputTypes {
'number' = 'number',
}

/**
* Check if an empty IDL value on the element could mean a derivation of displayed value and IDL value
*/
export function hasUnreliableEmptyValue(
element: Element,
): element is HTMLInputElement & {type: unreliableValueInputTypes} {
return (
isElementType(element, 'input') &&
Boolean(
unreliableValueInputTypes[
element.type as keyof typeof unreliableValueInputTypes
],
)
)
}

0 comments on commit d5aa3ee

Please sign in to comment.