diff --git a/src/__tests__/keyboard/plugin/control.ts b/src/__tests__/keyboard/plugin/control.ts
index 9f0e476a..224bcf6f 100644
--- a/src/__tests__/keyboard/plugin/control.ts
+++ b/src/__tests__/keyboard/plugin/control.ts
@@ -46,3 +46,14 @@ test('press [End] in contenteditable', () => {
expect(selection).toHaveProperty('focusNode', element?.firstChild)
expect(selection).toHaveProperty('focusOffset', 10)
})
+
+test('use [Delete] on number input', () => {
+ const {element} = setup(``)
+
+ userEvent.type(
+ element as HTMLInputElement,
+ '1e-5[ArrowLeft][Delete]6[ArrowLeft][ArrowLeft][ArrowLeft][Delete][Delete]',
+ )
+
+ expect(element).toHaveValue(16)
+})
diff --git a/src/__tests__/type.js b/src/__tests__/type.js
index 01b4bf1a..a59fc6a4 100644
--- a/src/__tests__/type.js
+++ b/src/__tests__/type.js
@@ -1399,3 +1399,12 @@ test('move selection with arrows', () => {
selectionStart: 1,
})
})
+
+test('overwrite selection with same value', () => {
+ const {element} = setup(``)
+ element.select()
+
+ userEvent.type(element, '11123')
+
+ expect(element).toHaveValue('11123')
+})
diff --git a/src/keyboard/plugins/character.ts b/src/keyboard/plugins/character.ts
index 00c02790..e09004be 100644
--- a/src/keyboard/plugins/character.ts
+++ b/src/keyboard/plugins/character.ts
@@ -3,12 +3,14 @@
*/
import {fireEvent} from '@testing-library/dom'
-import {fireChangeForInputTimeIfValid, fireInputEventIfNeeded} from '../shared'
+import {fireChangeForInputTimeIfValid, fireInputEvent} from '../shared'
import {behaviorPlugin} from '../types'
import {
buildTimeValue,
calculateNewValue,
+ getSpaceUntilMaxLength,
getValue,
+ isClickableInput,
isContentEditable,
isElementType,
isValidDateValue,
@@ -19,7 +21,7 @@ export const keypressBehavior: behaviorPlugin[] = [
{
matches: (keyDef, element) =>
keyDef.key?.length === 1 &&
- isElementType(element, 'input', {type: 'time'}),
+ isElementType(element, 'input', {type: 'time', readOnly: false}),
handle: (keyDef, element, options, state) => {
let newEntry = keyDef.key as string
@@ -38,16 +40,20 @@ export const keypressBehavior: behaviorPlugin[] = [
newEntry,
element as HTMLElement,
)
-
- const {prevValue} = fireInputEventIfNeeded({
- newValue,
- newSelectionStart,
- eventOverrides: {
- data: keyDef.key,
- inputType: 'insertText',
- },
- currentElement: () => element,
- })
+ const prevValue = getValue(element)
+
+ // this check was provided by fireInputEventIfNeeded
+ // TODO: verify if it is even needed by this handler
+ if (prevValue !== newValue) {
+ fireInputEvent(element as HTMLInputElement, {
+ newValue,
+ newSelectionStart,
+ eventOverrides: {
+ data: keyDef.key,
+ inputType: 'insertText',
+ },
+ })
+ }
fireChangeForInputTimeIfValid(
element as HTMLInputElement & {type: 'time'},
@@ -61,7 +67,7 @@ export const keypressBehavior: behaviorPlugin[] = [
{
matches: (keyDef, element) =>
keyDef.key?.length === 1 &&
- isElementType(element, 'input', {type: 'date'}),
+ isElementType(element, 'input', {type: 'date', readOnly: false}),
handle: (keyDef, element, options, state) => {
let newEntry = keyDef.key as string
@@ -78,16 +84,20 @@ export const keypressBehavior: behaviorPlugin[] = [
newEntry,
element as HTMLElement,
)
-
- fireInputEventIfNeeded({
- newValue,
- newSelectionStart,
- eventOverrides: {
- data: keyDef.key,
- inputType: 'insertText',
- },
- currentElement: () => element,
- })
+ const prevValue = getValue(element)
+
+ // this check was provided by fireInputEventIfNeeded
+ // TODO: verify if it is even needed by this handler
+ if (prevValue !== newValue) {
+ fireInputEvent(element as HTMLInputElement, {
+ newValue,
+ newSelectionStart,
+ eventOverrides: {
+ data: keyDef.key,
+ inputType: 'insertText',
+ },
+ })
+ }
if (isValidToBeTyped) {
fireEvent.change(element, {
@@ -101,7 +111,7 @@ export const keypressBehavior: behaviorPlugin[] = [
{
matches: (keyDef, element) =>
keyDef.key?.length === 1 &&
- isElementType(element, 'input', {type: 'number'}),
+ isElementType(element, 'input', {type: 'number', readOnly: false}),
handle: (keyDef, element, options, state) => {
if (!/[\d.\-e]/.test(keyDef.key as string)) {
return
@@ -116,14 +126,13 @@ export const keypressBehavior: behaviorPlugin[] = [
oldValue,
)
- fireInputEventIfNeeded({
+ fireInputEvent(element as HTMLInputElement, {
newValue,
newSelectionStart,
eventOverrides: {
data: keyDef.key,
inputType: 'insertText',
},
- currentElement: () => element,
})
const appliedValue = getValue(element)
@@ -137,29 +146,32 @@ export const keypressBehavior: behaviorPlugin[] = [
{
matches: (keyDef, element) =>
keyDef.key?.length === 1 &&
- (isElementType(element, ['input', 'textarea']) ||
- isContentEditable(element)),
+ ((isElementType(element, ['input', 'textarea'], {readOnly: false}) &&
+ !isClickableInput(element)) ||
+ isContentEditable(element)) &&
+ getSpaceUntilMaxLength(element) !== 0,
handle: (keyDef, element) => {
const {newValue, newSelectionStart} = calculateNewValue(
keyDef.key as string,
element as HTMLElement,
)
- fireInputEventIfNeeded({
+ fireInputEvent(element as HTMLElement, {
newValue,
newSelectionStart,
eventOverrides: {
data: keyDef.key,
inputType: 'insertText',
},
- currentElement: () => element,
})
},
},
{
matches: (keyDef, element) =>
keyDef.key === 'Enter' &&
- (isElementType(element, 'textarea') || isContentEditable(element)),
+ (isElementType(element, 'textarea', {readOnly: false}) ||
+ isContentEditable(element)) &&
+ getSpaceUntilMaxLength(element) !== 0,
handle: (keyDef, element, options, state) => {
const {newValue, newSelectionStart} = calculateNewValue(
'\n',
@@ -171,13 +183,12 @@ export const keypressBehavior: behaviorPlugin[] = [
? 'insertParagraph'
: 'insertLineBreak'
- fireInputEventIfNeeded({
+ fireInputEvent(element as HTMLElement, {
newValue,
newSelectionStart,
eventOverrides: {
inputType,
},
- currentElement: () => element,
})
},
},
diff --git a/src/keyboard/plugins/control.ts b/src/keyboard/plugins/control.ts
index 80a4f356..8616e6b6 100644
--- a/src/keyboard/plugins/control.ts
+++ b/src/keyboard/plugins/control.ts
@@ -5,13 +5,15 @@
import {behaviorPlugin} from '../types'
import {
+ calculateNewValue,
getValue,
isContentEditable,
+ isCursorAtEnd,
+ isEditable,
isElementType,
setSelectionRange,
} from '../../utils'
-import {fireInputEventIfNeeded} from '../shared'
-import {calculateNewDeleteValue} from './control/calculateNewDeleteValue'
+import {carryValue, fireInputEvent} from '../shared'
export const keydownBehavior: behaviorPlugin[] = [
{
@@ -30,15 +32,26 @@ export const keydownBehavior: behaviorPlugin[] = [
},
},
{
- matches: keyDef => keyDef.key === 'Delete',
- handle: (keDef, element) => {
- fireInputEventIfNeeded({
- ...calculateNewDeleteValue(element),
+ matches: (keyDef, element) =>
+ keyDef.key === 'Delete' && isEditable(element) && !isCursorAtEnd(element),
+ handle: (keDef, element, options, state) => {
+ const {newValue, newSelectionStart} = calculateNewValue(
+ '',
+ element as HTMLElement,
+ state.carryValue,
+ undefined,
+ 'forward',
+ )
+
+ fireInputEvent(element as HTMLElement, {
+ newValue,
+ newSelectionStart,
eventOverrides: {
inputType: 'deleteContentForward',
},
- currentElement: () => element,
})
+
+ carryValue(element, state, newValue)
},
},
]
diff --git a/src/keyboard/plugins/control/calculateNewDeleteValue.ts b/src/keyboard/plugins/control/calculateNewDeleteValue.ts
deleted file mode 100644
index febdde86..00000000
--- a/src/keyboard/plugins/control/calculateNewDeleteValue.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import {getSelectionRange, getValue} from '../../../utils'
-
-export function calculateNewDeleteValue(element: Element) {
- const {selectionStart, selectionEnd} = getSelectionRange(element)
-
- // istanbul ignore next
- const value = getValue(element) ?? ''
-
- let newValue
-
- if (selectionStart === null) {
- // at the end of an input type that does not support selection ranges
- // https://github.com/testing-library/user-event/issues/316#issuecomment-639744793
- newValue = value
- } else if (selectionStart === selectionEnd) {
- if (selectionStart === 0) {
- // at the beginning of the input
- newValue = value.slice(1)
- } else if (selectionStart === value.length) {
- // at the end of the input
- newValue = value
- } else {
- // in the middle of the input
- newValue = value.slice(0, selectionStart) + value.slice(selectionEnd + 1)
- }
- } else {
- // we have something selected
- const firstPart = value.slice(0, selectionStart)
- newValue = firstPart + value.slice(selectionEnd as number)
- }
-
- return {newValue, newSelectionStart: selectionStart ?? 0}
-}
diff --git a/src/keyboard/plugins/functional.ts b/src/keyboard/plugins/functional.ts
index 5d209a3c..bfbc0a89 100644
--- a/src/keyboard/plugins/functional.ts
+++ b/src/keyboard/plugins/functional.ts
@@ -4,11 +4,16 @@
*/
import {fireEvent} from '@testing-library/dom'
-import {getValue, isClickableInput, isElementType} from '../../utils'
+import {
+ calculateNewValue,
+ isClickableInput,
+ isCursorAtStart,
+ isEditable,
+ isElementType,
+} from '../../utils'
import {getKeyEventProps, getMouseEventProps} from '../getEventProps'
-import {fireInputEventIfNeeded} from '../shared'
+import {carryValue, fireInputEvent} from '../shared'
import {behaviorPlugin} from '../types'
-import {calculateNewBackspaceValue} from './functional/calculateBackspaceValue'
const modifierKeys = {
Alt: 'alt',
@@ -49,25 +54,28 @@ export const keydownBehavior: behaviorPlugin[] = [
},
},
{
- matches: keyDef => keyDef.key === 'Backspace',
+ matches: (keyDef, element) =>
+ keyDef.key === 'Backspace' &&
+ isEditable(element) &&
+ !isCursorAtStart(element),
handle: (keyDef, element, options, state) => {
- const {newValue, newSelectionStart} = calculateNewBackspaceValue(
- element,
+ const {newValue, newSelectionStart} = calculateNewValue(
+ '',
+ element as HTMLElement,
state.carryValue,
+ undefined,
+ 'backward',
)
- fireInputEventIfNeeded({
+ fireInputEvent(element as HTMLElement, {
newValue,
newSelectionStart,
eventOverrides: {
inputType: 'deleteContentBackward',
},
- currentElement: () => element,
})
- if (state.carryValue) {
- state.carryValue = getValue(element) === newValue ? undefined : newValue
- }
+ carryValue(element, state, newValue)
},
},
]
diff --git a/src/keyboard/plugins/functional/calculateBackspaceValue.ts b/src/keyboard/plugins/functional/calculateBackspaceValue.ts
deleted file mode 100644
index 4e544f61..00000000
--- a/src/keyboard/plugins/functional/calculateBackspaceValue.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import {getSelectionRange, getValue} from '../../../utils'
-
-// yes, calculateNewBackspaceValue and calculateNewValue look extremely similar
-// and you may be tempted to create a shared abstraction.
-// If you, brave soul, decide to so endevor, please increment this count
-// when you inevitably fail: 1
-export function calculateNewBackspaceValue(
- element: Element,
- value = getValue(element) ?? /* istanbul ignore next */ '',
-) {
- const {selectionStart, selectionEnd} = getSelectionRange(element)
-
- let newValue, newSelectionStart
-
- if (selectionStart === null) {
- // at the end of an input type that does not support selection ranges
- // https://github.com/testing-library/user-event/issues/316#issuecomment-639744793
- newValue = value.slice(0, value.length - 1)
-
- newSelectionStart = newValue.length
- } else if (selectionStart === selectionEnd) {
- if (selectionStart === 0) {
- // at the beginning of the input
- newValue = value
- newSelectionStart = selectionStart
- } else if (selectionStart === value.length) {
- // at the end of the input
- newValue = value.slice(0, value.length - 1)
- newSelectionStart = selectionStart - 1
- } else {
- // in the middle of the input
- newValue = value.slice(0, selectionStart - 1) + value.slice(selectionEnd)
- newSelectionStart = selectionStart - 1
- }
- } else {
- // we have something selected
- const firstPart = value.slice(0, selectionStart)
- newValue = firstPart + value.slice(selectionEnd as number)
- newSelectionStart = firstPart.length
- }
-
- return {newValue, newSelectionStart}
-}
diff --git a/src/keyboard/shared/carryValue.ts b/src/keyboard/shared/carryValue.ts
new file mode 100644
index 00000000..983c0cd4
--- /dev/null
+++ b/src/keyboard/shared/carryValue.ts
@@ -0,0 +1,14 @@
+import {getValue, hasUnreliableEmptyValue} from '../../utils'
+import {keyboardState} from '../types'
+
+export function carryValue(
+ element: Element,
+ state: keyboardState,
+ newValue: string,
+) {
+ const value = getValue(element)
+ state.carryValue =
+ value !== newValue && value === '' && hasUnreliableEmptyValue(element)
+ ? newValue
+ : undefined
+}
diff --git a/src/keyboard/shared/fireInputEventIfNeeded.ts b/src/keyboard/shared/fireInputEvent.ts
similarity index 54%
rename from src/keyboard/shared/fireInputEventIfNeeded.ts
rename to src/keyboard/shared/fireInputEvent.ts
index 9fc04ec8..97b34ac6 100644
--- a/src/keyboard/shared/fireInputEventIfNeeded.ts
+++ b/src/keyboard/shared/fireInputEvent.ts
@@ -1,64 +1,46 @@
import {fireEvent} from '@testing-library/dom'
import {
isElementType,
- isClickableInput,
getValue,
hasUnreliableEmptyValue,
isContentEditable,
setSelectionRange,
} from '../../utils'
-export function fireInputEventIfNeeded({
- currentElement,
- newValue,
- newSelectionStart,
- eventOverrides,
-}: {
- currentElement: () => Element | null
- newValue: string
- newSelectionStart: number
- eventOverrides: Partial[1]> & {
- [k: string]: unknown
- }
-}): {
- prevValue: string | null
-} {
- const el = currentElement()
- const prevValue = getValue(el)
- if (
- el &&
- !isReadonly(el) &&
- !isClickableInput(el) &&
- newValue !== prevValue
- ) {
- // apply the changes before firing the input event, so that input handlers can access the altered dom and selection
- if (isContentEditable(el)) {
- el.textContent = newValue
- } else /* istanbul ignore else */ if (isElementType(el, ['input', 'textarea'])) {
- el.value = newValue
- } else {
- // TODO: properly type guard
- throw new Error('Invalid Element')
+export function fireInputEvent(
+ element: HTMLElement,
+ {
+ newValue,
+ newSelectionStart,
+ eventOverrides,
+ }: {
+ newValue: string
+ newSelectionStart: number
+ eventOverrides: Partial[1]> & {
+ [k: string]: unknown
}
- setSelectionRangeAfterInput(el, newValue, newSelectionStart)
-
- fireEvent.input(el, {
- ...eventOverrides,
- })
-
- setSelectionRangeAfterInputHandler(el, newValue)
+ },
+) {
+ // apply the changes before firing the input event, so that input handlers can access the altered dom and selection
+ if (isContentEditable(element)) {
+ element.textContent = newValue
+ } else /* istanbul ignore else */ if (isElementType(element, ['input', 'textarea'])) {
+ element.value = newValue
+ } else {
+ // TODO: properly type guard
+ throw new Error('Invalid Element')
}
+ setSelectionRangeAfterInput(element, newSelectionStart)
- return {prevValue}
-}
+ fireEvent.input(element, {
+ ...eventOverrides,
+ })
-function isReadonly(element: Element): boolean {
- return isElementType(element, ['input', 'textarea'], {readOnly: true})
+ setSelectionRangeAfterInputHandler(element, newValue)
}
function setSelectionRangeAfterInput(
element: Element,
- newValue: string,
newSelectionStart: number,
) {
setSelectionRange(element, newSelectionStart, newSelectionStart)
diff --git a/src/keyboard/shared/index.ts b/src/keyboard/shared/index.ts
index b4ba531a..30de28ac 100644
--- a/src/keyboard/shared/index.ts
+++ b/src/keyboard/shared/index.ts
@@ -1,2 +1,3 @@
+export * from './carryValue'
export * from './fireChangeForInputTimeIfValid'
-export * from './fireInputEventIfNeeded'
+export * from './fireInputEvent'
diff --git a/src/paste.ts b/src/paste.ts
index 14a64ee7..4bd4ae4e 100644
--- a/src/paste.ts
+++ b/src/paste.ts
@@ -1,5 +1,6 @@
import {fireEvent} from '@testing-library/dom'
import {
+ getSpaceUntilMaxLength,
setSelectionRange,
calculateNewValue,
eventWrapper,
@@ -47,23 +48,27 @@ function paste(
fireEvent.paste(element, init)
- if (!element.readOnly) {
- const {newValue, newSelectionStart} = calculateNewValue(text, element)
- fireEvent.input(element, {
- inputType: 'insertFromPaste',
- target: {value: newValue},
- })
- setSelectionRange(
- element,
-
- // TODO: investigate why the selection caused by invalid parameters was expected
- ({
- newSelectionStart,
- selectionEnd: newSelectionStart,
- } as unknown) as number,
- ({} as unknown) as number,
- )
+ if (element.readOnly) {
+ return
}
+
+ text = text.substr(0, getSpaceUntilMaxLength(element))
+
+ const {newValue, newSelectionStart} = calculateNewValue(text, element)
+ fireEvent.input(element, {
+ inputType: 'insertFromPaste',
+ target: {value: newValue},
+ })
+ setSelectionRange(
+ element,
+
+ // TODO: investigate why the selection caused by invalid parameters was expected
+ ({
+ newSelectionStart,
+ selectionEnd: newSelectionStart,
+ } as unknown) as number,
+ ({} as unknown) as number,
+ )
}
export {paste}
diff --git a/src/type/typeImplementation.ts b/src/type/typeImplementation.ts
index 466c0b62..31ee2494 100644
--- a/src/type/typeImplementation.ts
+++ b/src/type/typeImplementation.ts
@@ -47,7 +47,10 @@ export async function typeImplementation(
const {selectionStart, selectionEnd} = getSelectionRange(element)
- if (value != null && selectionStart === 0 && selectionEnd === 0) {
+ if (value != null &&
+ (selectionStart === null || selectionStart === 0) &&
+ (selectionEnd === null || selectionEnd === 0)
+ ) {
setSelectionRange(
currentElement() as Element,
initialSelectionStart ?? value.length,
diff --git a/src/utils/edit/calculateNewValue.ts b/src/utils/edit/calculateNewValue.ts
index 81b45e5a..417e509b 100644
--- a/src/utils/edit/calculateNewValue.ts
+++ b/src/utils/edit/calculateNewValue.ts
@@ -1,4 +1,3 @@
-import {isElementType} from '../misc/isElementType'
import {getSelectionRange} from './selectionRange'
import {getValue} from './getValue'
import {isValidDateValue} from './isValidDateValue'
@@ -7,47 +6,45 @@ import {isValidInputTimeValue} from './isValidInputTimeValue'
export function calculateNewValue(
newEntry: string,
element: HTMLElement,
- value = getValue(element),
+ value = getValue(element) ?? /* istanbul ignore next */ '',
selectionRange = getSelectionRange(element),
+ deleteContent?: 'backward' | 'forward',
): {
newValue: string
newSelectionStart: number
} {
- const {selectionStart, selectionEnd} = selectionRange
+ const selectionStart =
+ selectionRange.selectionStart === null
+ ? value.length
+ : selectionRange.selectionStart
+ const selectionEnd =
+ selectionRange.selectionEnd === null
+ ? value.length
+ : selectionRange.selectionEnd
- let newValue: string, newSelectionStart: number
+ const prologEnd = Math.max(
+ 0,
+ selectionStart === selectionEnd && deleteContent === 'backward'
+ ? selectionStart - 1
+ : selectionStart,
+ )
+ const prolog = value.substring(0, prologEnd)
+ const epilogStart = Math.min(
+ value.length,
+ selectionStart === selectionEnd && deleteContent === 'forward'
+ ? selectionEnd + 1
+ : selectionEnd,
+ )
+ const epilog = value.substring(epilogStart, value.length)
- if (selectionStart === null) {
- // at the end of an input type that does not support selection ranges
- // https://github.com/testing-library/user-event/issues/316#issuecomment-639744793
- newValue = `${value}${newEntry}`
- newSelectionStart = newValue.length
- } else if (selectionStart === selectionEnd) {
- if (selectionStart === 0) {
- // at the beginning of the input
- newValue = `${newEntry}${value}`
- } else if (selectionStart === value?.length) {
- // at the end of the input
- newValue = `${value}${newEntry}`
- } else {
- // in the middle of the input
- newValue = `${value?.slice(0, selectionStart)}${newEntry}${value?.slice(
- selectionEnd,
- )}`
- }
- newSelectionStart = selectionStart + newEntry.length
- } else {
- // we have something selected
- const firstPart = `${value?.slice(0, selectionStart)}${newEntry}`
- newValue = `${firstPart}${value?.slice(selectionEnd as number)}`
- newSelectionStart = firstPart.length
- }
+ let newValue = `${prolog}${newEntry}${epilog}`
+ const newSelectionStart = prologEnd + newEntry.length
if (
(element as HTMLInputElement).type === 'date' &&
!isValidDateValue(element as HTMLInputElement & {type: 'date'}, newValue)
) {
- newValue = value as string
+ newValue = value
}
if (
@@ -65,50 +62,12 @@ export function calculateNewValue(
) {
newValue = newEntry
} else {
- newValue = value as string
- }
- }
-
- // can't use .maxLength property because of a jsdom bug:
- // https://github.com/jsdom/jsdom/issues/2927
- const maxLength = getSanitizedMaxLength(element)
-
- if (maxLength === undefined) {
- return {
- newValue,
- newSelectionStart,
- }
- } else {
- return {
- newValue: newValue.slice(0, maxLength),
- newSelectionStart:
- newSelectionStart > maxLength ? maxLength : newSelectionStart,
+ newValue = value
}
}
-}
-function getSanitizedMaxLength(element: Element) {
- if (!supportsMaxLength(element)) {
- return undefined
+ return {
+ newValue,
+ newSelectionStart,
}
-
- const attr = element.getAttribute('maxlength') ?? ''
-
- return /^\d+$/.test(attr) && Number(attr) >= 0 ? Number(attr) : undefined
-}
-
-function supportsMaxLength(element: Element) {
- if (isElementType(element, 'textarea')) return true
-
- if (isElementType(element, 'input')) {
- const type = element.getAttribute('type')
-
- // Missing value default is "text"
- if (!type) return true
-
- // https://html.spec.whatwg.org/multipage/input.html#concept-input-apply
- if (type.match(/email|password|search|telephone|text|url/)) return true
- }
-
- return false
}
diff --git a/src/utils/edit/cursorPosition.ts b/src/utils/edit/cursorPosition.ts
new file mode 100644
index 00000000..03f3f42a
--- /dev/null
+++ b/src/utils/edit/cursorPosition.ts
@@ -0,0 +1,21 @@
+import {getSelectionRange} from './selectionRange'
+import {getValue} from './getValue'
+
+export function isCursorAtEnd(element: Element) {
+ const {selectionStart, selectionEnd} = getSelectionRange(element)
+
+ return (
+ selectionStart === selectionEnd &&
+ (selectionStart ?? /* istanbul ignore next */ 0) ===
+ (getValue(element) ?? /* istanbul ignore next */ '').length
+ )
+}
+
+export function isCursorAtStart(element: Element) {
+ const {selectionStart, selectionEnd} = getSelectionRange(element)
+
+ return (
+ selectionStart === selectionEnd &&
+ (selectionStart ?? /* istanbul ignore next */ 0) === 0
+ )
+}
diff --git a/src/utils/edit/isContentEditable.ts b/src/utils/edit/isContentEditable.ts
index 877e63a8..197003d0 100644
--- a/src/utils/edit/isContentEditable.ts
+++ b/src/utils/edit/isContentEditable.ts
@@ -1,5 +1,5 @@
//jsdom is not supporting isContentEditable
-export function isContentEditable(element: Element): boolean {
+export function isContentEditable(element: Element): element is HTMLElement & { contenteditable: 'true' } {
return (
element.hasAttribute('contenteditable') &&
(element.getAttribute('contenteditable') == 'true' ||
diff --git a/src/utils/edit/isEditable.ts b/src/utils/edit/isEditable.ts
new file mode 100644
index 00000000..ac53db06
--- /dev/null
+++ b/src/utils/edit/isEditable.ts
@@ -0,0 +1,42 @@
+import { isElementType } from "../misc/isElementType";
+import { isContentEditable } from './isContentEditable'
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type GuardedType = T extends (x: any) => x is (infer R) ? R : never
+
+export function isEditable(
+ element: Element
+): element is
+ GuardedType
+ | GuardedType
+ | HTMLTextAreaElement & {readOnly: false}
+{
+ return isEditableInput(element)
+ || isElementType(element, 'textarea', {readOnly: false})
+ || isContentEditable(element)
+}
+
+enum editableInputTypes {
+ 'text' = 'text',
+ 'date' = 'date',
+ 'datetime-local' = 'datetime-local',
+ 'email' = 'email',
+ 'month' = 'month',
+ 'number' = 'number',
+ 'password' = 'password',
+ 'search' = 'search',
+ 'tel' = 'tel',
+ 'time' = 'time',
+ 'url' = 'url',
+ 'week' = 'week',
+}
+
+export function isEditableInput(
+ element: Element
+): element is HTMLInputElement & {
+ readOnly: false,
+ type: editableInputTypes
+} {
+ return isElementType(element, 'input', {readOnly: false})
+ && Boolean(editableInputTypes[element.type as editableInputTypes])
+}
diff --git a/src/utils/edit/maxLength.ts b/src/utils/edit/maxLength.ts
new file mode 100644
index 00000000..a0ce88a9
--- /dev/null
+++ b/src/utils/edit/maxLength.ts
@@ -0,0 +1,52 @@
+import {isElementType} from '../misc/isElementType'
+import {getValue} from './getValue'
+
+enum maxLengthSupportedTypes {
+ 'email' = 'email',
+ 'password' = 'password',
+ 'search' = 'search',
+ 'telephone' = 'telephone',
+ 'text' = 'text',
+ 'url' = 'url',
+}
+
+export function getSpaceUntilMaxLength(element: Element) {
+ const value = getValue(element)
+
+ /* istanbul ignore if */
+ if (value === null) {
+ return undefined
+ }
+
+ const maxLength = getSanitizedMaxLength(element)
+
+ return maxLength ? maxLength - value.length : undefined
+}
+
+// can't use .maxLength property because of a jsdom bug:
+// https://github.com/jsdom/jsdom/issues/2927
+function getSanitizedMaxLength(element: Element) {
+ if (!supportsMaxLength(element)) {
+ return undefined
+ }
+
+ const attr = element.getAttribute('maxlength') ?? ''
+
+ return /^\d+$/.test(attr) && Number(attr) >= 0 ? Number(attr) : undefined
+}
+
+function supportsMaxLength(
+ element: Element,
+): element is
+ | HTMLTextAreaElement
+ | (HTMLInputElement & {type: maxLengthSupportedTypes}) {
+ return (
+ isElementType(element, 'textarea') ||
+ (isElementType(element, 'input') &&
+ Boolean(
+ maxLengthSupportedTypes[
+ element.type as keyof typeof maxLengthSupportedTypes
+ ],
+ ))
+ )
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 6945dac3..f87571fc 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -3,11 +3,14 @@ export * from './click/isClickableInput'
export * from './edit/buildTimeValue'
export * from './edit/calculateNewValue'
+export * from './edit/cursorPosition'
export * from './edit/getValue'
export * from './edit/hasUnreliableEmptyValue'
export * from './edit/isContentEditable'
+export * from './edit/isEditable'
export * from './edit/isValidDateValue'
export * from './edit/isValidInputTimeValue'
+export * from './edit/maxLength'
export * from './edit/selectionRange'
export * from './focus/getActiveElement'