Skip to content

Commit d5aa3ee

Browse files
authored
fix: handle selectionRange on different input types (#619)
* wip: selectionRange * test: remove testPathIgnorePattern for utils * fix: refactor selection handling * fix: {selectall} on number input
1 parent 7ff9a9a commit d5aa3ee

18 files changed

+284
-140
lines changed

jest.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,7 @@ const config = require('kcd-scripts/jest')
33
module.exports = {
44
...config,
55
testEnvironment: 'jest-environment-jsdom',
6+
7+
// this repo is testing utils
8+
testPathIgnorePatterns: config.testPathIgnorePatterns.filter(f => f !== '/__tests__/utils/'),
69
}

src/__tests__/type.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1416,3 +1416,11 @@ test('type non-alphanumeric characters', () => {
14161416

14171417
expect(element).toHaveValue('https://test.local')
14181418
})
1419+
1420+
test('use {selectall} on <input type="number"/>', () => {
1421+
const {element} = setup(`<input type="number" value="0"/>`)
1422+
1423+
userEvent.type(element, '123{selectall}{backspace}4')
1424+
1425+
expect(element).toHaveValue(4)
1426+
})
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {setup} from '__tests__/helpers/utils'
2+
import {isContentEditable} from '../../../utils'
3+
4+
test('report if element is contenteditable', () => {
5+
const {elements} = setup(
6+
`<div></div><div contenteditable="false"></div><div contenteditable></div><div contenteditable="true"></div>`,
7+
)
8+
9+
expect(isContentEditable(elements[0])).toBe(false)
10+
expect(isContentEditable(elements[1])).toBe(false)
11+
expect(isContentEditable(elements[2])).toBe(true)
12+
expect(isContentEditable(elements[3])).toBe(true)
13+
})
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {getSelectionRange, setSelectionRange} from 'utils'
2+
import {setup} from '__tests__/helpers/utils'
3+
4+
test('range on input', () => {
5+
const {element} = setup('<input value="foo"/>')
6+
7+
expect(getSelectionRange(element as HTMLInputElement)).toEqual({
8+
selectionStart: 0,
9+
selectionEnd: 0,
10+
})
11+
12+
setSelectionRange(element as HTMLInputElement, 0, 0)
13+
14+
expect(element).toHaveProperty('selectionStart', 0)
15+
expect(element).toHaveProperty('selectionEnd', 0)
16+
expect(getSelectionRange(element as HTMLInputElement)).toEqual({
17+
selectionStart: 0,
18+
selectionEnd: 0,
19+
})
20+
21+
setSelectionRange(element as HTMLInputElement, 2, 3)
22+
23+
expect(element).toHaveProperty('selectionStart', 2)
24+
expect(element).toHaveProperty('selectionEnd', 3)
25+
expect(getSelectionRange(element as HTMLInputElement)).toEqual({
26+
selectionStart: 2,
27+
selectionEnd: 3,
28+
})
29+
})
30+
31+
test('range on contenteditable', () => {
32+
const {element} = setup('<div contenteditable="true">foo</div>')
33+
34+
expect(getSelectionRange(element as HTMLInputElement)).toEqual({
35+
selectionStart: null,
36+
selectionEnd: null,
37+
})
38+
39+
setSelectionRange(element as HTMLDivElement, 0, 0)
40+
41+
expect(getSelectionRange(element as HTMLInputElement)).toEqual({
42+
selectionStart: 0,
43+
selectionEnd: 0,
44+
})
45+
46+
setSelectionRange(element as HTMLDivElement, 2, 3)
47+
48+
expect(document.getSelection()?.anchorNode).toBe(element?.firstChild)
49+
expect(document.getSelection()?.focusNode).toBe(element?.firstChild)
50+
expect(document.getSelection()?.anchorOffset).toBe(2)
51+
expect(document.getSelection()?.focusOffset).toBe(3)
52+
expect(getSelectionRange(element as HTMLInputElement)).toEqual({
53+
selectionStart: 2,
54+
selectionEnd: 3,
55+
})
56+
})
57+
58+
test('range on input without selection support', () => {
59+
const {element} = setup(`<input type="number" value="123"/>`)
60+
61+
expect(getSelectionRange(element as HTMLInputElement)).toEqual({
62+
selectionStart: null,
63+
selectionEnd: null,
64+
})
65+
66+
setSelectionRange(element as HTMLInputElement, 1, 2)
67+
68+
expect(getSelectionRange(element as HTMLInputElement)).toEqual({
69+
selectionStart: 1,
70+
selectionEnd: 2,
71+
})
72+
})

src/keyboard/plugins/arrow.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import {behaviorPlugin} from '../types'
7-
import {isElementType, setSelectionRangeIfNecessary} from '../../utils'
7+
import {isElementType, setSelectionRange} from '../../utils'
88

99
export const keydownBehavior: behaviorPlugin[] = [
1010
{
@@ -24,7 +24,7 @@ export const keydownBehavior: behaviorPlugin[] = [
2424
? selectionStart
2525
: selectionEnd) ?? /* istanbul ignore next */ 0
2626

27-
setSelectionRangeIfNecessary(element, newPos, newPos)
27+
setSelectionRange(element, newPos, newPos)
2828
},
2929
},
3030
]

src/keyboard/plugins/control.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
getValue,
99
isContentEditable,
1010
isElementType,
11-
setSelectionRangeIfNecessary,
11+
setSelectionRange,
1212
} from '../../utils'
1313
import {fireInputEventIfNeeded} from '../shared'
1414
import {calculateNewDeleteValue} from './control/calculateNewDeleteValue'
@@ -22,10 +22,10 @@ export const keydownBehavior: behaviorPlugin[] = [
2222
handle: (keyDef, element) => {
2323
// This could probably been improved by collapsing a selection range
2424
if (keyDef.key === 'Home') {
25-
setSelectionRangeIfNecessary(element, 0, 0)
25+
setSelectionRange(element, 0, 0)
2626
} else {
2727
const newPos = getValue(element)?.length ?? /* istanbul ignore next */ 0
28-
setSelectionRangeIfNecessary(element, newPos, newPos)
28+
setSelectionRange(element, newPos, newPos)
2929
}
3030
},
3131
},

src/keyboard/plugins/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {behaviorPlugin} from '../types'
2-
import {isElementType} from '../../utils'
2+
import {isElementType, setSelectionRange} from '../../utils'
33
import * as arrowKeys from './arrow'
44
import * as controlKeys from './control'
55
import * as characterKeys from './character'
@@ -10,8 +10,15 @@ export const replaceBehavior: behaviorPlugin[] = [
1010
matches: (keyDef, element) =>
1111
keyDef.key === 'selectall' &&
1212
isElementType(element, ['input', 'textarea']),
13-
handle: (keyDef, element) => {
14-
;(element as HTMLInputElement).select()
13+
handle: (keyDef, element, options, state) => {
14+
setSelectionRange(
15+
element,
16+
0,
17+
(
18+
state.carryValue ??
19+
(element as HTMLInputElement | HTMLTextAreaElement).value
20+
).length,
21+
)
1522
},
1623
},
1724
]

src/keyboard/shared/fireInputEventIfNeeded.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import {
33
isElementType,
44
isClickableInput,
55
getValue,
6+
hasUnreliableEmptyValue,
67
isContentEditable,
8+
setSelectionRange,
79
} from '../../utils'
8-
import {setSelectionRange} from './setSelectionRange'
910

1011
export function fireInputEventIfNeeded({
1112
currentElement,
@@ -42,11 +43,7 @@ export function fireInputEventIfNeeded({
4243
})
4344
}
4445

45-
setSelectionRange({
46-
currentElement,
47-
newValue,
48-
newSelectionStart,
49-
})
46+
setSelectionRangeAfterInput(el, newValue, newSelectionStart)
5047
}
5148

5249
return {prevValue}
@@ -55,3 +52,30 @@ export function fireInputEventIfNeeded({
5552
function isReadonly(element: Element): boolean {
5653
return isElementType(element, ['input', 'textarea'], {readOnly: true})
5754
}
55+
56+
function setSelectionRangeAfterInput(
57+
element: Element,
58+
newValue: string,
59+
newSelectionStart: number,
60+
) {
61+
// if we *can* change the selection start, then we will if the new value
62+
// is the same as the current value (so it wasn't programatically changed
63+
// when the fireEvent.input was triggered).
64+
// The reason we have to do this at all is because it actually *is*
65+
// programmatically changed by fireEvent.input, so we have to simulate the
66+
// browser's default behavior
67+
const value = getValue(element) as string
68+
69+
// don't apply this workaround on elements that don't necessarily report the visible value - e.g. number
70+
if (
71+
value === newValue ||
72+
(value === '' && hasUnreliableEmptyValue(element))
73+
) {
74+
setSelectionRange(element, newSelectionStart, newSelectionStart)
75+
} else {
76+
// If the currentValue is different than the expected newValue and we *can*
77+
// change the selection range, than we should set it to the length of the
78+
// currentValue to ensure that the browser behavior is mimicked.
79+
setSelectionRange(element, value.length, value.length)
80+
}
81+
}

src/keyboard/shared/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
export * from './fireChangeForInputTimeIfValid'
22
export * from './fireInputEventIfNeeded'
3-
export * from './setSelectionRange'

src/keyboard/shared/setSelectionRange.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.

0 commit comments

Comments
 (0)