From 394d4254ba935f66b2d5e3d24b7e48b1252bd72b Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 25 Mar 2021 10:26:11 +0100 Subject: [PATCH] fix: dispatch input events when overwriting selection (#623) * wip: clean up fireInputEvent helper * fix(type): setSelectionRange on elements without selectionRange * refactor: maxLength * test: demonstrate #583 * fix: refactor calculateNewValue * fix: relative imports --- src/__tests__/keyboard/plugin/control.ts | 11 ++ src/__tests__/type.js | 9 ++ src/keyboard/plugins/character.ts | 77 +++++++------ src/keyboard/plugins/control.ts | 27 +++-- .../control/calculateNewDeleteValue.ts | 33 ------ src/keyboard/plugins/functional.ts | 30 +++-- .../functional/calculateBackspaceValue.ts | 43 -------- src/keyboard/shared/carryValue.ts | 14 +++ ...nputEventIfNeeded.ts => fireInputEvent.ts} | 70 +++++------- src/keyboard/shared/index.ts | 3 +- src/paste.ts | 37 ++++--- src/type/typeImplementation.ts | 5 +- src/utils/edit/calculateNewValue.ts | 103 ++++++------------ src/utils/edit/cursorPosition.ts | 21 ++++ src/utils/edit/isContentEditable.ts | 2 +- src/utils/edit/isEditable.ts | 42 +++++++ src/utils/edit/maxLength.ts | 52 +++++++++ src/utils/index.ts | 3 + 18 files changed, 320 insertions(+), 262 deletions(-) delete mode 100644 src/keyboard/plugins/control/calculateNewDeleteValue.ts delete mode 100644 src/keyboard/plugins/functional/calculateBackspaceValue.ts create mode 100644 src/keyboard/shared/carryValue.ts rename src/keyboard/shared/{fireInputEventIfNeeded.ts => fireInputEvent.ts} (54%) create mode 100644 src/utils/edit/cursorPosition.ts create mode 100644 src/utils/edit/isEditable.ts create mode 100644 src/utils/edit/maxLength.ts 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'