diff --git a/src/document/index.ts b/src/document/index.ts index 821ea4aa..ec0dda9b 100644 --- a/src/document/index.ts +++ b/src/document/index.ts @@ -76,3 +76,4 @@ function prepareElement(el: Node | HTMLInputElement) { export {getUIValue, setUIValue, startTrackValue, endTrackValue} from './value' export {getUISelection, setUISelection} from './selection' +export type {UISelectionRange} from './selection' diff --git a/src/document/selection.ts b/src/document/selection.ts index 1c423362..6f01c507 100644 --- a/src/document/selection.ts +++ b/src/document/selection.ts @@ -1,3 +1,4 @@ +import {getUIValue} from '.' import {prepareInterceptor} from './interceptor' const UISelection = Symbol('Displayed selection in UI') @@ -6,9 +7,19 @@ interface Value extends Number { [UISelection]?: typeof UISelection } +export interface UISelectionRange { + startOffset: number + endOffset: number +} + +export interface UISelection { + anchorOffset: number + focusOffset: number +} + declare global { interface Element { - [UISelection]?: {start: number; end: number} + [UISelection]?: UISelection } } @@ -26,9 +37,9 @@ export function prepareSelectionInterceptor( ) { const isUI = start && typeof start === 'object' && start[UISelection] - this[UISelection] = isUI - ? {start: start.valueOf(), end: Number(end)} - : undefined + if (!isUI) { + this[UISelection] = undefined + } return { realArgs: [Number(start), end, direction] as [ @@ -62,21 +73,45 @@ export function prepareSelectionInterceptor( export function setUISelection( element: HTMLInputElement | HTMLTextAreaElement, - start: number, - end: number, + { + focusOffset: focusOffsetParam, + anchorOffset: anchorOffsetParam = focusOffsetParam, + }: { + anchorOffset?: number + focusOffset: number + }, + mode: 'replace' | 'modify' = 'replace', ) { - element[UISelection] = {start, end} + const valueLength = getUIValue(element).length + const sanitizeOffset = (o: number) => Math.max(0, Math.min(valueLength, o)) + + const anchorOffset = + mode === 'replace' || element[UISelection] === undefined + ? sanitizeOffset(anchorOffsetParam) + : (element[UISelection] as UISelection).anchorOffset + const focusOffset = sanitizeOffset(focusOffsetParam) + + const startOffset = Math.min(anchorOffset, focusOffset) + const endOffset = Math.max(anchorOffset, focusOffset) - if (element.selectionStart === start && element.selectionEnd === end) { + element[UISelection] = { + anchorOffset, + focusOffset, + } + + if ( + element.selectionStart === startOffset && + element.selectionEnd === endOffset + ) { return } // eslint-disable-next-line no-new-wrappers - const startObj = new Number(start) + const startObj = new Number(startOffset) ;(startObj as Value)[UISelection] = UISelection try { - element.setSelectionRange(startObj as number, end) + element.setSelectionRange(startObj as number, endOffset) } catch { // DOMException for invalid state is expected when calling this // on an element without support for setSelectionRange @@ -86,16 +121,15 @@ export function setUISelection( export function getUISelection( element: HTMLInputElement | HTMLTextAreaElement, ) { - const ui = element[UISelection] - return ui === undefined - ? { - selectionStart: element.selectionStart, - selectionEnd: element.selectionEnd, - } - : { - selectionStart: ui.start, - selectionEnd: ui.end, - } + const sel = element[UISelection] ?? { + anchorOffset: element.selectionStart ?? 0, + focusOffset: element.selectionEnd ?? 0, + } + return { + ...sel, + startOffset: Math.min(sel.anchorOffset, sel.focusOffset), + endOffset: Math.max(sel.anchorOffset, sel.focusOffset), + } } export function clearUISelection( diff --git a/src/document/value.ts b/src/document/value.ts index 4dbd11bc..7e763a11 100644 --- a/src/document/value.ts +++ b/src/document/value.ts @@ -56,7 +56,9 @@ export function setUIValue( } export function getUIValue(element: HTMLInputElement | HTMLTextAreaElement) { - return element[UIValue] === undefined ? element.value : element[UIValue] + return element[UIValue] === undefined + ? element.value + : String(element[UIValue]) } export function setInitialValue( diff --git a/src/keyboard/plugins/arrow.ts b/src/keyboard/plugins/arrow.ts index f95130ed..0b6c00bd 100644 --- a/src/keyboard/plugins/arrow.ts +++ b/src/keyboard/plugins/arrow.ts @@ -4,7 +4,8 @@ */ import {behaviorPlugin} from '../types' -import {getSelectionRange, isElementType, setSelectionRange} from '../../utils' +import {isElementType, setSelection} from '../../utils' +import {getUISelection} from '../../document' export const keydownBehavior: behaviorPlugin[] = [ { @@ -13,18 +14,18 @@ export const keydownBehavior: behaviorPlugin[] = [ (keyDef.key === 'ArrowLeft' || keyDef.key === 'ArrowRight') && isElementType(element, ['input', 'textarea']), handle: (keyDef, element) => { - const {selectionStart, selectionEnd} = getSelectionRange(element) + const selection = getUISelection(element as HTMLInputElement) - const direction = keyDef.key === 'ArrowLeft' ? -1 : 1 - - const newPos = - (selectionStart === selectionEnd - ? (selectionStart ?? /* istanbul ignore next */ 0) + direction - : direction < 0 - ? selectionStart - : selectionEnd) ?? /* istanbul ignore next */ 0 - - setSelectionRange(element, newPos, newPos) + // TODO: implement shift/ctrl + setSelection({ + focusNode: element, + focusOffset: + selection.startOffset === selection.endOffset + ? selection.focusOffset + (keyDef.key === 'ArrowLeft' ? -1 : 1) + : keyDef.key === 'ArrowLeft' + ? selection.startOffset + : selection.endOffset, + }) }, }, ] diff --git a/src/keyboard/plugins/character.ts b/src/keyboard/plugins/character.ts index 8aa97ffc..d4d1317a 100644 --- a/src/keyboard/plugins/character.ts +++ b/src/keyboard/plugins/character.ts @@ -9,14 +9,17 @@ import { buildTimeValue, calculateNewValue, fireInputEvent, + getInputRange, getSpaceUntilMaxLength, getValue, - isClickableInput, isContentEditable, + isEditableInput, isElementType, isValidDateValue, isValidInputTimeValue, + prepareInput, } from '../../utils' +import {UISelectionRange} from '../../document' export const keypressBehavior: behaviorPlugin[] = [ { @@ -37,9 +40,10 @@ export const keypressBehavior: behaviorPlugin[] = [ newEntry = timeNewEntry } - const {newValue, newSelectionStart} = calculateNewValue( + const {newValue, newOffset} = calculateNewValue( newEntry, - element as HTMLElement, + element as HTMLInputElement & {type: 'time'}, + getInputRange(element) as UISelectionRange, ) const prevValue = getValue(element) @@ -48,7 +52,10 @@ export const keypressBehavior: behaviorPlugin[] = [ if (prevValue !== newValue) { fireInputEvent(element as HTMLInputElement, { newValue, - newSelectionStart, + newSelection: { + node: element, + offset: newOffset, + }, eventOverrides: { data: keyDef.key, inputType: 'insertText', @@ -81,9 +88,10 @@ export const keypressBehavior: behaviorPlugin[] = [ newEntry = textToBeTyped } - const {newValue, newSelectionStart} = calculateNewValue( + const {newValue, newOffset} = calculateNewValue( newEntry, - element as HTMLElement, + element as HTMLInputElement & {type: 'date'}, + getInputRange(element) as UISelectionRange, ) const prevValue = getValue(element) @@ -92,7 +100,10 @@ export const keypressBehavior: behaviorPlugin[] = [ if (prevValue !== newValue) { fireInputEvent(element as HTMLInputElement, { newValue, - newSelectionStart, + newSelection: { + node: element, + offset: newOffset, + }, eventOverrides: { data: keyDef.key, inputType: 'insertText', @@ -118,10 +129,10 @@ export const keypressBehavior: behaviorPlugin[] = [ return } - const {newValue, newSelectionStart} = calculateNewValue( + const {newValue, commit} = prepareInput( keyDef.key as string, - element as HTMLElement, - ) + element, + ) as NonNullable> // the browser allows some invalid input but not others // it allows up to two '-' at any place before any 'e' or one directly following 'e' @@ -135,37 +146,18 @@ export const keypressBehavior: behaviorPlugin[] = [ return } - fireInputEvent(element as HTMLInputElement, { - newValue, - newSelectionStart, - eventOverrides: { - data: keyDef.key, - inputType: 'insertText', - }, - }) + commit() }, }, { matches: (keyDef, element) => keyDef.key?.length === 1 && - ((isElementType(element, ['input', 'textarea'], {readOnly: false}) && - !isClickableInput(element)) || + (isEditableInput(element) || + isElementType(element, 'textarea', {readOnly: false}) || isContentEditable(element)) && getSpaceUntilMaxLength(element) !== 0, handle: (keyDef, element) => { - const {newValue, newSelectionStart} = calculateNewValue( - keyDef.key as string, - element as HTMLElement, - ) - - fireInputEvent(element as HTMLElement, { - newValue, - newSelectionStart, - eventOverrides: { - data: keyDef.key, - inputType: 'insertText', - }, - }) + prepareInput(keyDef.key as string, element)?.commit() }, }, { @@ -175,23 +167,13 @@ export const keypressBehavior: behaviorPlugin[] = [ isContentEditable(element)) && getSpaceUntilMaxLength(element) !== 0, handle: (keyDef, element, options, state) => { - const {newValue, newSelectionStart} = calculateNewValue( + prepareInput( '\n', - element as HTMLElement, - ) - - const inputType = + element, isContentEditable(element) && !state.modifiers.shift ? 'insertParagraph' - : 'insertLineBreak' - - fireInputEvent(element as HTMLElement, { - newValue, - newSelectionStart, - eventOverrides: { - inputType, - }, - }) + : 'insertLineBreak', + )?.commit() }, }, ] diff --git a/src/keyboard/plugins/control.ts b/src/keyboard/plugins/control.ts index c38008f5..1d97b163 100644 --- a/src/keyboard/plugins/control.ts +++ b/src/keyboard/plugins/control.ts @@ -5,13 +5,11 @@ import {behaviorPlugin} from '../types' import { - calculateNewValue, - fireInputEvent, getValue, isContentEditable, - isCursorAtEnd, isEditable, isElementType, + prepareInput, setSelectionRange, } from '../../utils' @@ -47,23 +45,9 @@ export const keydownBehavior: behaviorPlugin[] = [ }, { matches: (keyDef, element) => - keyDef.key === 'Delete' && isEditable(element) && !isCursorAtEnd(element), + keyDef.key === 'Delete' && isEditable(element), handle: (keDef, element) => { - const {newValue, newSelectionStart} = calculateNewValue( - '', - element as HTMLElement, - undefined, - undefined, - 'forward', - ) - - fireInputEvent(element as HTMLElement, { - newValue, - newSelectionStart, - eventOverrides: { - inputType: 'deleteContentForward', - }, - }) + prepareInput('', element, 'deleteContentForward')?.commit() }, }, ] diff --git a/src/keyboard/plugins/functional.ts b/src/keyboard/plugins/functional.ts index 347511e8..dd69f322 100644 --- a/src/keyboard/plugins/functional.ts +++ b/src/keyboard/plugins/functional.ts @@ -7,15 +7,13 @@ import {fireEvent} from '@testing-library/dom' import {setUISelection} from '../../document' import { blur, - calculateNewValue, - fireInputEvent, focus, getTabDestination, hasFormSubmit, isClickableInput, - isCursorAtStart, isEditable, isElementType, + prepareInput, } from '../../utils' import {getKeyEventProps, getMouseEventProps} from '../getEventProps' import {behaviorPlugin} from '../types' @@ -60,25 +58,9 @@ export const keydownBehavior: behaviorPlugin[] = [ }, { matches: (keyDef, element) => - keyDef.key === 'Backspace' && - isEditable(element) && - !isCursorAtStart(element), + keyDef.key === 'Backspace' && isEditable(element), handle: (keyDef, element) => { - const {newValue, newSelectionStart} = calculateNewValue( - '', - element as HTMLElement, - undefined, - undefined, - 'backward', - ) - - fireInputEvent(element as HTMLElement, { - newValue, - newSelectionStart, - eventOverrides: { - inputType: 'deleteContentBackward', - }, - }) + prepareInput('', element, 'deleteContentBackward')?.commit() }, }, { @@ -90,7 +72,10 @@ export const keydownBehavior: behaviorPlugin[] = [ } else { focus(dest) if (isElementType(dest, ['input', 'textarea'])) { - setUISelection(dest, 0, dest.value.length) + setUISelection(dest, { + anchorOffset: 0, + focusOffset: dest.value.length, + }) } } }, diff --git a/src/paste.ts b/src/paste.ts index d306ba66..e8c53c66 100644 --- a/src/paste.ts +++ b/src/paste.ts @@ -3,11 +3,12 @@ import type {UserEvent} from './setup' import { getSpaceUntilMaxLength, setSelectionRange, - calculateNewValue, eventWrapper, isDisabled, isElementType, editableInputTypes, + getInputRange, + prepareInput, } from './utils' interface pasteOptions { @@ -72,19 +73,12 @@ export function paste( text = text.substr(0, getSpaceUntilMaxLength(element)) - const {newValue, newSelectionStart} = calculateNewValue(text, element) - fireEvent.input(element, { - inputType: 'insertFromPaste', - target: {value: newValue}, - }) - setSelectionRange( - element, + const inputRange = getInputRange(element) - // TODO: investigate why the selection caused by invalid parameters was expected - { - newSelectionStart, - selectionEnd: newSelectionStart, - } as unknown as number, - {} as unknown as number, - ) + /* istanbul ignore if */ + if (!inputRange) { + return + } + + prepareInput(text, element, 'insertFromPaste')?.commit() } diff --git a/src/pointer/pointerMove.ts b/src/pointer/pointerMove.ts index aafb6ecb..aed97ca9 100644 --- a/src/pointer/pointerMove.ts +++ b/src/pointer/pointerMove.ts @@ -57,29 +57,38 @@ export async function pointerMove( fireMove(target, coords) if (selectionRange) { + // TODO: support extending range (shift) + const selectionFocus = resolveSelectionTarget({target, node, offset}) - if ( - 'node' in selectionRange && - selectionFocus.node === selectionRange.node - ) { - setUISelection( - selectionRange.node, - Math.min(selectionRange.start, selectionFocus.offset), - Math.max(selectionRange.end, selectionFocus.offset), - ) - } else /* istanbul ignore else */ if ('setEnd' in selectionRange) { + if ('node' in selectionRange) { + // When the mouse is dragged outside of an input/textarea, + // the selection is extended to the beginning or end of the input + // depending on pointer position. + // TODO: extend selection according to pointer position + /* istanbul ignore else */ + if (selectionFocus.node === selectionRange.node) { + const anchorOffset = + selectionFocus.offset < selectionRange.start + ? selectionRange.end + : selectionRange.start + const focusOffset = + selectionFocus.offset > selectionRange.end || + selectionFocus.offset < selectionRange.start + ? selectionFocus.offset + : selectionRange.end + + setUISelection(selectionRange.node, {anchorOffset, focusOffset}) + } + } else { const range = selectionRange.cloneRange() - const cmp = selectionRange.comparePoint( - selectionFocus.node, - selectionFocus.offset, - ) + + const cmp = range.comparePoint(selectionFocus.node, selectionFocus.offset) if (cmp < 0) { range.setStart(selectionFocus.node, selectionFocus.offset) } else if (cmp > 0) { range.setEnd(selectionFocus.node, selectionFocus.offset) } - // TODO: support multiple ranges const selection = target.ownerDocument.getSelection() as Selection selection.removeAllRanges() selection.addRange(range.cloneRange()) diff --git a/src/pointer/pointerPress.ts b/src/pointer/pointerPress.ts index 179bcb8c..a517464d 100644 --- a/src/pointer/pointerPress.ts +++ b/src/pointer/pointerPress.ts @@ -293,8 +293,12 @@ function mousedownDefaultBehavior({ [offset, offset] : getTextRange(text, offset, clickCount) + // TODO: implement modifying selection per shift/ctrl+mouse if (hasValue) { - setUISelection(target, start ?? text.length, end ?? text.length) + setUISelection(target, { + anchorOffset: start ?? text.length, + focusOffset: end ?? text.length, + }) position.selectionRange = { node: target, start: start ?? 0, @@ -318,7 +322,6 @@ function mousedownDefaultBehavior({ position.selectionRange = range - // TODO: support multiple ranges const selection = target.ownerDocument.getSelection() as Selection selection.removeAllRanges() selection.addRange(range.cloneRange()) diff --git a/src/pointer/resolveSelectionTarget.ts b/src/pointer/resolveSelectionTarget.ts index 849cebe5..a970b5da 100644 --- a/src/pointer/resolveSelectionTarget.ts +++ b/src/pointer/resolveSelectionTarget.ts @@ -10,8 +10,7 @@ export function resolveSelectionTarget({ if (isElementType(target, ['input', 'textarea'])) { return { node: target, - offset: - offset ?? (getUIValue(target) ?? /* istanbul ignore next */ '').length, + offset: offset ?? getUIValue(target).length, } } else if (node) { return { @@ -58,10 +57,16 @@ function findNodeAtTextOffset( offset -= text.length } else if (c.nodeType === 1) { return findNodeAtTextOffset(c as Element, offset, false) - } else /* istanbul ignore else */ if (c.nodeType === 3) { - return { - node: c as Node, - offset: offset ?? (c.nodeValue as string).length, + } else { + // The pre-commit hooks keeps changing this + // See https://github.com/kentcdodds/kcd-scripts/issues/218 + /* istanbul ignore else */ + // eslint-disable-next-line no-lonely-if + if (c.nodeType === 3) { + return { + node: c as Node, + offset: offset ?? (c.nodeValue as string).length, + } } } } diff --git a/src/utils/edit/calculateNewValue.ts b/src/utils/edit/calculateNewValue.ts index 417e509b..a2cb0724 100644 --- a/src/utils/edit/calculateNewValue.ts +++ b/src/utils/edit/calculateNewValue.ts @@ -1,73 +1,80 @@ -import {getSelectionRange} from './selectionRange' -import {getValue} from './getValue' +import {getUIValue} from '../../document' +import {EditableInputType} from './isEditable' import {isValidDateValue} from './isValidDateValue' import {isValidInputTimeValue} from './isValidInputTimeValue' +/** + * Calculate a new text value. + */ +// This implementation does not properly calculate a new DOM state. +// It only handles text values and neither cares for DOM offsets nor accounts for non-character elements. +// It can be used for text nodes and elements supporting value property. +// TODO: The implementation of `deleteContent` is brittle and should be replaced. export function calculateNewValue( - newEntry: string, - element: HTMLElement, - value = getValue(element) ?? /* istanbul ignore next */ '', - selectionRange = getSelectionRange(element), - deleteContent?: 'backward' | 'forward', -): { - newValue: string - newSelectionStart: number -} { - const selectionStart = - selectionRange.selectionStart === null - ? value.length - : selectionRange.selectionStart - const selectionEnd = - selectionRange.selectionEnd === null - ? value.length - : selectionRange.selectionEnd + inputData: string, + node: + | (HTMLInputElement & {type: EditableInputType}) + | HTMLTextAreaElement + | (Node & {nodeType: 3}) + | Text, + { + startOffset, + endOffset, + }: { + startOffset: number + endOffset: number + }, + inputType?: string, +) { + const value = + node.nodeType === 3 + ? String(node.nodeValue) + : getUIValue(node as HTMLInputElement) const prologEnd = Math.max( 0, - selectionStart === selectionEnd && deleteContent === 'backward' - ? selectionStart - 1 - : selectionStart, + startOffset === endOffset && inputType === 'deleteContentBackward' + ? startOffset - 1 + : startOffset, ) const prolog = value.substring(0, prologEnd) const epilogStart = Math.min( value.length, - selectionStart === selectionEnd && deleteContent === 'forward' - ? selectionEnd + 1 - : selectionEnd, + startOffset === endOffset && inputType === 'deleteContentForward' + ? startOffset + 1 + : endOffset, ) const epilog = value.substring(epilogStart, value.length) - let newValue = `${prolog}${newEntry}${epilog}` - const newSelectionStart = prologEnd + newEntry.length + let newValue = `${prolog}${inputData}${epilog}` + const newOffset = prologEnd + inputData.length if ( - (element as HTMLInputElement).type === 'date' && - !isValidDateValue(element as HTMLInputElement & {type: 'date'}, newValue) + (node as HTMLInputElement).type === 'date' && + !isValidDateValue(node as HTMLInputElement & {type: 'date'}, newValue) ) { newValue = value } if ( - (element as HTMLInputElement).type === 'time' && - !isValidInputTimeValue( - element as HTMLInputElement & {type: 'time'}, - newValue, - ) + (node as HTMLInputElement).type === 'time' && + !isValidInputTimeValue(node as HTMLInputElement & {type: 'time'}, newValue) ) { if ( isValidInputTimeValue( - element as HTMLInputElement & {type: 'time'}, - newEntry, + node as HTMLInputElement & {type: 'time'}, + inputData, ) ) { - newValue = newEntry + newValue = inputData } else { newValue = value } } return { + oldValue: value, newValue, - newSelectionStart, + newOffset, } } diff --git a/src/utils/edit/cursorPosition.ts b/src/utils/edit/cursorPosition.ts deleted file mode 100644 index 03f3f42a..00000000 --- a/src/utils/edit/cursorPosition.ts +++ /dev/null @@ -1,21 +0,0 @@ -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/fireInputEvent.ts b/src/utils/edit/fireInputEvent.ts index ec4eacda..9177c1c5 100644 --- a/src/utils/edit/fireInputEvent.ts +++ b/src/utils/edit/fireInputEvent.ts @@ -1,18 +1,20 @@ import {fireEvent} from '@testing-library/dom' -import {isElementType} from '../misc/isElementType' import {setUIValue, startTrackValue, endTrackValue} from '../../document' -import {isContentEditable} from './isContentEditable' -import {setSelectionRange} from './selectionRange' +import {isElementType} from '../misc/isElementType' +import {setSelection} from '../focus/selection' export function fireInputEvent( element: HTMLElement, { newValue, - newSelectionStart, + newSelection, eventOverrides, }: { newValue: string - newSelectionStart: number + newSelection: { + node: Node + offset: number + } eventOverrides: Partial[1]> & { [k: string]: unknown } @@ -21,21 +23,25 @@ export function fireInputEvent( const oldValue = (element as HTMLInputElement).value // 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 + if (isElementType(element, ['input', 'textarea'])) { + setUIValue(element, newValue) } else { // The pre-commit hooks keeps changing this // See https://github.com/kentcdodds/kcd-scripts/issues/218 /* istanbul ignore else */ // eslint-disable-next-line no-lonely-if - if (isElementType(element, ['input', 'textarea'])) { - setUIValue(element, newValue) + if (newSelection.node.nodeType === 3) { + newSelection.node.textContent = newValue } else { // TODO: properly type guard throw new Error('Invalid Element') } } - setSelectionRange(element, newSelectionStart, newSelectionStart) + setSelection({ + focusNode: newSelection.node, + anchorOffset: newSelection.offset, + focusOffset: newSelection.offset, + }) // When the input event happens in the browser, React executes all event handlers // and if they change state of a controlled value, nothing happens. @@ -56,6 +62,10 @@ export function fireInputEvent( tracked[0] === oldValue && tracked[1] === newValue ) { - setSelectionRange(element, newSelectionStart, newSelectionStart) + setSelection({ + focusNode: newSelection.node, + anchorOffset: newSelection.offset, + focusOffset: newSelection.offset, + }) } } diff --git a/src/utils/edit/isContentEditable.ts b/src/utils/edit/isContentEditable.ts index 197003d0..67c7b579 100644 --- a/src/utils/edit/isContentEditable.ts +++ b/src/utils/edit/isContentEditable.ts @@ -1,8 +1,26 @@ //jsdom is not supporting isContentEditable -export function isContentEditable(element: Element): element is HTMLElement & { contenteditable: 'true' } { +export function isContentEditable( + element: Element, +): element is HTMLElement & {contenteditable: 'true'} { return ( element.hasAttribute('contenteditable') && (element.getAttribute('contenteditable') == 'true' || element.getAttribute('contenteditable') == '') ) } + +/** + * If a node is a contenteditable or inside one, return that element. + */ +export function getContentEditable(node: Node): Element | null { + const element = getElement(node) + return ( + element && + (element.closest('[contenteditable=""]') || + element.closest('[contenteditable="true"]')) + ) +} + +function getElement(node: Node) { + return node.nodeType === 1 ? (node as Element) : node.parentElement +} diff --git a/src/utils/edit/isEditable.ts b/src/utils/edit/isEditable.ts index 08733e6b..54193f65 100644 --- a/src/utils/edit/isEditable.ts +++ b/src/utils/edit/isEditable.ts @@ -32,6 +32,8 @@ export enum editableInputTypes { 'week' = 'week', } +export type EditableInputType = keyof typeof editableInputTypes + export function isEditableInput( element: Element, ): element is HTMLInputElement & { diff --git a/src/utils/edit/prepareInput.ts b/src/utils/edit/prepareInput.ts new file mode 100644 index 00000000..7230e6a5 --- /dev/null +++ b/src/utils/edit/prepareInput.ts @@ -0,0 +1,85 @@ +import {UISelectionRange} from '../../document' +import { + calculateNewValue, + EditableInputType, + fireInputEvent, + getInputRange, +} from '../../utils' + +export function prepareInput( + data: string, + element: Element, + inputType: string = 'insertText', +): + | { + newValue: string + commit: () => void + } + | undefined { + const inputRange = getInputRange(element) + + // TODO: implement for ranges on multiple nodes + /* istanbul ignore if */ + if ( + !inputRange || + ('startContainer' in inputRange && + inputRange.startContainer !== inputRange.endContainer) + ) { + return + } + const node = getNode(element, inputRange) + + const {newValue, newOffset, oldValue} = calculateNewValue( + data, + node, + inputRange, + inputType, + ) + + if ( + newValue === oldValue && + newOffset === inputRange.startOffset && + newOffset === inputRange.endOffset + ) { + return + } + + return { + newValue, + commit: () => + fireInputEvent(element as HTMLElement, { + newValue, + newSelection: { + node, + offset: newOffset, + }, + eventOverrides: { + inputType, + }, + }), + } +} + +function getNode(element: Element, inputRange: Range | UISelectionRange) { + if ('startContainer' in inputRange) { + if (inputRange.startContainer.nodeType === 3) { + return inputRange.startContainer as Text + } + + try { + return inputRange.startContainer.insertBefore( + element.ownerDocument.createTextNode(''), + inputRange.startContainer.childNodes.item(inputRange.startOffset), + ) + } catch { + /* istanbul ignore next */ + throw new Error( + 'Invalid operation. Can not insert text at this position. The behavior is not implemented yet.', + ) + } + } + + return element as + | HTMLTextAreaElement + | (HTMLInputElement & {type: EditableInputType}) +} diff --git a/src/utils/edit/selectionRange.ts b/src/utils/edit/selectionRange.ts deleted file mode 100644 index 57c83df4..00000000 --- a/src/utils/edit/selectionRange.ts +++ /dev/null @@ -1,65 +0,0 @@ -import {isElementType} from '../misc/isElementType' -import {getUISelection, setUISelection} from '../../document' - -export function getSelectionRange(element: Element): { - selectionStart: number | null - selectionEnd: number | null -} { - if (isElementType(element, ['input', 'textarea'])) { - return getUISelection(element) - } - - const selection = element.ownerDocument.getSelection() - - // there should be no editing if the focusNode is outside of element - // TODO: properly handle selection ranges - if (selection?.rangeCount && element.contains(selection.focusNode)) { - const range = selection.getRangeAt(0) - return { - selectionStart: range.startOffset, - selectionEnd: range.endOffset, - } - } else { - return { - selectionStart: null, - selectionEnd: null, - } - } -} - -export function setSelectionRange( - element: Element, - newSelectionStart: number, - newSelectionEnd: number, -) { - if (isElementType(element, ['input', 'textarea'])) { - return setUISelection(element, newSelectionStart, newSelectionEnd) - } - - const {selectionStart, selectionEnd} = getSelectionRange(element) - - // Prevent unnecessary select events - // istanbul ignore next - if ( - selectionStart === newSelectionStart && - selectionEnd === newSelectionEnd - ) { - return - } - - const range = element.ownerDocument.createRange() - range.selectNodeContents(element) - - // istanbul ignore else - if (element.firstChild) { - range.setStart(element.firstChild, newSelectionStart) - range.setEnd(element.firstChild, newSelectionEnd) - } - - const selection = element.ownerDocument.getSelection() - // istanbul ignore else - if (selection) { - selection.removeAllRanges() - selection.addRange(range) - } -} diff --git a/src/utils/focus/focus.ts b/src/utils/focus/focus.ts index c178825f..e41a6708 100644 --- a/src/utils/focus/focus.ts +++ b/src/utils/focus/focus.ts @@ -1,6 +1,7 @@ import {eventWrapper} from '../misc/eventWrapper' import {getActiveElement} from './getActiveElement' import {isFocusable} from './isFocusable' +import {updateSelectionOnFocus} from './selection' function focus(element: Element) { if (!isFocusable(element)) return @@ -9,6 +10,8 @@ function focus(element: Element) { if (isAlreadyActive) return eventWrapper(() => element.focus()) + + updateSelectionOnFocus(element) } export {focus} diff --git a/src/utils/focus/selection.ts b/src/utils/focus/selection.ts new file mode 100644 index 00000000..a60bc716 --- /dev/null +++ b/src/utils/focus/selection.ts @@ -0,0 +1,196 @@ +import {isElementType} from '../misc/isElementType' +import {getUISelection, setUISelection, UISelectionRange} from '../../document' +import {editableInputTypes} from '../edit/isEditable' +import {isContentEditable, getContentEditable} from '../edit/isContentEditable' + +/** + * Backward-compatible selection. + * + * Handles input elements and contenteditable if it only contains a single text node. + */ +export function setSelectionRange( + element: Element, + anchorOffset: number, + focusOffset: number, +) { + if (hasOwnSelection(element)) { + return setSelection({ + focusNode: element, + anchorOffset, + focusOffset, + }) + } + + /* istanbul ignore else */ + if (isContentEditable(element) && element.firstChild?.nodeType === 3) { + return setSelection({ + focusNode: element.firstChild, + anchorOffset, + focusOffset, + }) + } + + /* istanbul ignore next */ + throw new Error( + 'Not implemented. The result of this interaction is unreliable.', + ) +} + +/** + * Determine if the element has its own selection implementation + * and does not interact with the Document Selection API. + */ +export function hasOwnSelection( + node: Node, +): node is + | HTMLTextAreaElement + | (HTMLInputElement & {type: editableInputTypes}) { + return ( + isElement(node) && + (isElementType(node, 'textarea') || + (isElementType(node, 'input') && node.type in editableInputTypes)) + ) +} + +function isElement(node: Node): node is Element { + return node.nodeType === 1 +} + +/** + * Determine which selection logic and selection ranges to consider. + */ +function getTargetTypeAndSelection(node: Node) { + const element = getElement(node) + + if (element && hasOwnSelection(element)) { + return { + type: 'input', + selection: getUISelection(element), + } as const + } + + const selection = element?.ownerDocument.getSelection() + + // It is possible to extend a single-range selection into a contenteditable. + // This results in the range acting like a range outside of contenteditable. + const isCE = + getContentEditable(node) && + selection?.anchorNode && + getContentEditable(selection.anchorNode) + + return { + type: isCE ? 'contenteditable' : 'default', + selection, + } as const +} + +function getElement(node: Node) { + return node.nodeType === 1 ? (node as Element) : node.parentElement +} + +/** + * Reset the Document Selection when moving focus into an element + * with own selection implementation. + */ +export function updateSelectionOnFocus(element: Element) { + const selection = element.ownerDocument.getSelection() + + /* istanbul ignore if */ + if (!selection?.focusNode) { + return + } + + // If the focus moves inside an element with own selection implementation, + // the document selection will be this element. + // But if the focused element is inside a contenteditable, + // 1) a collapsed selection will be retained. + // 2) other selections will be replaced by a cursor + // 2.a) at the start of the first child if it is a text node + // 2.b) at the start of the contenteditable. + if (hasOwnSelection(element)) { + const contenteditable = getContentEditable(selection.focusNode) + if (contenteditable) { + if (!selection.isCollapsed) { + const focusNode = + contenteditable.firstChild?.nodeType === 3 + ? contenteditable.firstChild + : contenteditable + selection.setBaseAndExtent(focusNode, 0, focusNode, 0) + } + } else { + selection.setBaseAndExtent(element, 0, element, 0) + } + } +} + +/** + * Get the range that would be overwritten by input. + */ +export function getInputRange( + focusNode: Node, +): UISelectionRange | Range | undefined { + const typeAndSelection = getTargetTypeAndSelection(focusNode) + + if (typeAndSelection.type === 'input') { + return typeAndSelection.selection + } else if (typeAndSelection.type === 'contenteditable') { + // Multi-range on contenteditable edits the first selection instead of the last + return typeAndSelection.selection?.getRangeAt(0) + } +} + +/** + * Extend/shrink the selection like with Shift+Arrows or Shift+Mouse + */ +export function modifySelection({ + focusNode, + focusOffset, +}: { + focusNode: Node + /** DOM Offset */ + focusOffset: number +}) { + const typeAndSelection = getTargetTypeAndSelection(focusNode) + + if (typeAndSelection.type === 'input') { + return setUISelection( + focusNode as HTMLInputElement, + { + anchorOffset: typeAndSelection.selection.anchorOffset, + focusOffset, + }, + 'modify', + ) + } + + focusNode.ownerDocument?.getSelection()?.extend(focusNode, focusOffset) +} + +/** + * Set the selection + */ +export function setSelection({ + focusNode, + focusOffset, + anchorNode = focusNode, + anchorOffset = focusOffset, +}: { + anchorNode?: Node + /** DOM offset */ + anchorOffset?: number + focusNode: Node + focusOffset: number +}) { + const typeAndSelection = getTargetTypeAndSelection(focusNode) + + if (typeAndSelection.type === 'input') { + return setUISelection(focusNode as HTMLInputElement, { + anchorOffset, + focusOffset, + }) + } + + anchorNode.ownerDocument + ?.getSelection() + ?.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset) +} diff --git a/src/utils/index.ts b/src/utils/index.ts index dfac1929..4df41556 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,7 +2,6 @@ export * from './click/isClickableInput' export * from './edit/buildTimeValue' export * from './edit/calculateNewValue' -export * from './edit/cursorPosition' export * from './edit/fireInputEvent' export * from './edit/getValue' export * from './edit/isContentEditable' @@ -10,13 +9,14 @@ export * from './edit/isEditable' export * from './edit/isValidDateValue' export * from './edit/isValidInputTimeValue' export * from './edit/maxLength' -export * from './edit/selectionRange' +export * from './edit/prepareInput' export * from './focus/blur' export * from './focus/focus' export * from './focus/getActiveElement' export * from './focus/getTabDestination' export * from './focus/isFocusable' +export * from './focus/selection' export * from './focus/selector' export * from './keyDef/readNextDescriptor' diff --git a/tests/click/tripleClick.ts b/tests/click/tripleClick.ts index d7cddc7c..34fa7dcc 100644 --- a/tests/click/tripleClick.ts +++ b/tests/click/tripleClick.ts @@ -10,7 +10,12 @@ test('select input per triple click', () => { userEvent.tripleClick(element) expect(element).toHaveFocus() - expect(getUISelection(element)).toEqual({selectionStart: 0, selectionEnd: 7}) + expect(getUISelection(element)).toEqual( + expect.objectContaining({ + startOffset: 0, + endOffset: 7, + }), + ) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="foo bar"] diff --git a/tests/document/index.ts b/tests/document/index.ts index e0356a27..034b66ee 100644 --- a/tests/document/index.ts +++ b/tests/document/index.ts @@ -68,12 +68,10 @@ test('maintain selection range like UI', () => { element.setSelectionRange(1, 1) element.focus() setUIValue(element, 'adbc') - setUISelection(element, 2, 2) + setUISelection(element, {focusOffset: 2}) - expect(getUISelection(element)).toEqual({ - selectionStart: 2, - selectionEnd: 2, - }) + expect(getUISelection(element)).toHaveProperty('startOffset', 2) + expect(getUISelection(element)).toHaveProperty('endOffset', 2) expect(element.selectionStart).toBe(2) }) @@ -84,12 +82,10 @@ test('maintain selection range on elements without support for selection range', element.focus() setUIValue(element, '2e-') - setUISelection(element, 2, 2) + setUISelection(element, {focusOffset: 2}) - expect(getUISelection(element)).toEqual({ - selectionStart: 2, - selectionEnd: 2, - }) + expect(getUISelection(element)).toHaveProperty('startOffset', 2) + expect(getUISelection(element)).toHaveProperty('endOffset', 2) expect(element.selectionStart).toBe(null) }) @@ -100,21 +96,26 @@ test('clear UI selection if selection is programmatically set', () => { element.focus() setUIValue(element, 'abc') - setUISelection(element, 1, 2) + setUISelection(element, {anchorOffset: 1, focusOffset: 2}) expect(element.selectionStart).toBe(1) element.setSelectionRange(0, 1) - expect(getUISelection(element)).toEqual({selectionStart: 0, selectionEnd: 1}) + expect(getUISelection(element)).toHaveProperty('startOffset', 0) + expect(getUISelection(element)).toHaveProperty('endOffset', 1) - setUISelection(element, 2, 3) - expect(getUISelection(element)).toEqual({selectionStart: 2, selectionEnd: 3}) + setUISelection(element, {anchorOffset: 2, focusOffset: 3}) + expect(getUISelection(element)).toHaveProperty('startOffset', 2) + expect(getUISelection(element)).toHaveProperty('endOffset', 3) element.selectionEnd = 1 - expect(getUISelection(element)).toEqual({selectionStart: 1, selectionEnd: 1}) + expect(getUISelection(element)).toHaveProperty('startOffset', 1) + expect(getUISelection(element)).toHaveProperty('endOffset', 1) - setUISelection(element, 0, 1) - expect(getUISelection(element)).toEqual({selectionStart: 0, selectionEnd: 1}) + setUISelection(element, {anchorOffset: 1, focusOffset: 0}) + expect(getUISelection(element)).toHaveProperty('startOffset', 0) + expect(getUISelection(element)).toHaveProperty('endOffset', 1) element.selectionStart = 2 - expect(getUISelection(element)).toEqual({selectionStart: 2, selectionEnd: 2}) + expect(getUISelection(element)).toHaveProperty('startOffset', 2) + expect(getUISelection(element)).toHaveProperty('endOffset', 2) }) diff --git a/tests/document/selectionRange.ts b/tests/document/selectionRange.ts deleted file mode 100644 index 57e682a2..00000000 --- a/tests/document/selectionRange.ts +++ /dev/null @@ -1,72 +0,0 @@ -import {getSelectionRange, setSelectionRange} from '#src/utils' -import {setup} from '#testHelpers/utils' - -test('range on input', () => { - const {element} = setup('') - - expect(getSelectionRange(element)).toEqual({ - selectionStart: 0, - selectionEnd: 0, - }) - - setSelectionRange(element, 0, 0) - - expect(element).toHaveProperty('selectionStart', 0) - expect(element).toHaveProperty('selectionEnd', 0) - expect(getSelectionRange(element)).toEqual({ - selectionStart: 0, - selectionEnd: 0, - }) - - setSelectionRange(element, 2, 3) - - expect(element).toHaveProperty('selectionStart', 2) - expect(element).toHaveProperty('selectionEnd', 3) - expect(getSelectionRange(element)).toEqual({ - selectionStart: 2, - selectionEnd: 3, - }) -}) - -test('range on contenteditable', () => { - const {element} = setup('
foo
') - - expect(getSelectionRange(element)).toEqual({ - selectionStart: null, - selectionEnd: null, - }) - - setSelectionRange(element, 0, 0) - - expect(getSelectionRange(element)).toEqual({ - selectionStart: 0, - selectionEnd: 0, - }) - - setSelectionRange(element, 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)).toEqual({ - selectionStart: 2, - selectionEnd: 3, - }) -}) - -test('range on input without selection support', () => { - const {element} = setup(``) - - expect(getSelectionRange(element)).toEqual({ - selectionStart: null, - selectionEnd: null, - }) - - setSelectionRange(element, 1, 2) - - expect(getSelectionRange(element)).toEqual({ - selectionStart: 1, - selectionEnd: 2, - }) -}) diff --git a/tests/keyboard/keyboardImplementation.ts b/tests/keyboard/keyboardImplementation.ts index c8cebef0..216c63dc 100644 --- a/tests/keyboard/keyboardImplementation.ts +++ b/tests/keyboard/keyboardImplementation.ts @@ -34,15 +34,13 @@ describe('pressing and releasing keys', () => { userEvent.keyboard('{ArrowLeft>}{ArrowLeft}') expect(getEventSnapshot()).toMatchInlineSnapshot(` -Events fired on: input[value=""] - -input[value=""] - keydown: ArrowLeft (37) -input[value=""] - select -input[value=""] - keyup: ArrowLeft (37) -input[value=""] - keydown: ArrowLeft (37) -input[value=""] - select -input[value=""] - keyup: ArrowLeft (37) -`) + Events fired on: input[value=""] + + input[value=""] - keydown: ArrowLeft (37) + input[value=""] - keyup: ArrowLeft (37) + input[value=""] - keydown: ArrowLeft (37) + input[value=""] - keyup: ArrowLeft (37) + `) }) it('fires event without releasing key', () => { @@ -54,12 +52,12 @@ input[value=""] - keyup: ArrowLeft (37) userEvent.keyboard('{a>}') expect(getEventSnapshot()).toMatchInlineSnapshot(` -Events fired on: input[value="a"] + Events fired on: input[value="a"] -input[value=""] - keydown: a (97) -input[value=""] - keypress: a (97) -input[value="a"] - input -`) + input[value=""] - keydown: a (97) + input[value=""] - keypress: a (97) + input[value="a"] - input + `) }) it('fires event multiple times without releasing key', () => { @@ -70,15 +68,15 @@ input[value="a"] - input userEvent.keyboard('{a>2}') expect(getEventSnapshot()).toMatchInlineSnapshot(` -Events fired on: input[value="aa"] - -input[value=""] - keydown: a (97) -input[value=""] - keypress: a (97) -input[value="a"] - input -input[value="a"] - keydown: a (97) -input[value="a"] - keypress: a (97) -input[value="aa"] - input -`) + Events fired on: input[value="aa"] + + input[value=""] - keydown: a (97) + input[value=""] - keypress: a (97) + input[value="a"] - input + input[value="a"] - keydown: a (97) + input[value="a"] - keypress: a (97) + input[value="aa"] - input + `) }) it('fires event multiple times and releases key', () => { @@ -89,16 +87,16 @@ input[value="aa"] - input userEvent.keyboard('{a>2/}') expect(getEventSnapshot()).toMatchInlineSnapshot(` -Events fired on: input[value="aa"] - -input[value=""] - keydown: a (97) -input[value=""] - keypress: a (97) -input[value="a"] - input -input[value="a"] - keydown: a (97) -input[value="a"] - keypress: a (97) -input[value="aa"] - input -input[value="aa"] - keyup: a (97) -`) + Events fired on: input[value="aa"] + + input[value=""] - keydown: a (97) + input[value=""] - keypress: a (97) + input[value="a"] - input + input[value="a"] - keydown: a (97) + input[value="a"] - keypress: a (97) + input[value="aa"] - input + input[value="aa"] - keyup: a (97) + `) }) it('fires event multiple times for multiple keys', () => { @@ -109,28 +107,28 @@ input[value="aa"] - keyup: a (97) userEvent.keyboard('{a>2}{b>2/}{c>2}{/a}') expect(getEventSnapshot()).toMatchInlineSnapshot(` -Events fired on: input[value="aabbcc"] - -input[value=""] - keydown: a (97) -input[value=""] - keypress: a (97) -input[value="a"] - input -input[value="a"] - keydown: a (97) -input[value="a"] - keypress: a (97) -input[value="aa"] - input -input[value="aa"] - keydown: b (98) -input[value="aa"] - keypress: b (98) -input[value="aab"] - input -input[value="aab"] - keydown: b (98) -input[value="aab"] - keypress: b (98) -input[value="aabb"] - input -input[value="aabb"] - keyup: b (98) -input[value="aabb"] - keydown: c (99) -input[value="aabb"] - keypress: c (99) -input[value="aabbc"] - input -input[value="aabbc"] - keydown: c (99) -input[value="aabbc"] - keypress: c (99) -input[value="aabbcc"] - input -input[value="aabbcc"] - keyup: a (97) -`) + Events fired on: input[value="aabbcc"] + + input[value=""] - keydown: a (97) + input[value=""] - keypress: a (97) + input[value="a"] - input + input[value="a"] - keydown: a (97) + input[value="a"] - keypress: a (97) + input[value="aa"] - input + input[value="aa"] - keydown: b (98) + input[value="aa"] - keypress: b (98) + input[value="aab"] - input + input[value="aab"] - keydown: b (98) + input[value="aab"] - keypress: b (98) + input[value="aabb"] - input + input[value="aabb"] - keyup: b (98) + input[value="aabb"] - keydown: c (99) + input[value="aabb"] - keypress: c (99) + input[value="aabbc"] - input + input[value="aabbc"] - keydown: c (99) + input[value="aabbc"] - keypress: c (99) + input[value="aabbcc"] - input + input[value="aabbcc"] - keyup: a (97) + `) }) }) diff --git a/tests/keyboard/plugin/character.ts b/tests/keyboard/plugin/character.ts index 69d9620c..a3fd5c29 100644 --- a/tests/keyboard/plugin/character.ts +++ b/tests/keyboard/plugin/character.ts @@ -14,9 +14,8 @@ test('type [Enter] in textarea', () => { test('type [Enter] in contenteditable', () => { const {element, getEvents} = setup(`
f
`) - element.focus() - userEvent.keyboard('oo[Enter]bar[ShiftLeft>][Enter]baz') + userEvent.type(element, 'oo[Enter]bar[ShiftLeft>][Enter]baz') expect(element).toHaveTextContent('foo bar baz') expect(element.firstChild).toHaveProperty('nodeValue', 'foo\nbar\nbaz') diff --git a/tests/keyboard/plugin/control.ts b/tests/keyboard/plugin/control.ts index 8d1dbfdf..bd767101 100644 --- a/tests/keyboard/plugin/control.ts +++ b/tests/keyboard/plugin/control.ts @@ -57,3 +57,14 @@ test('use [Delete] on number input', () => { expect(element).toHaveValue(16) }) + +test('use [Delete] on contenteditable', () => { + const {element} = setup(`
foo bar baz
`) + const text = element.firstChild as Text + element.focus() + document.getSelection()?.setBaseAndExtent(text, 1, text, 9) + + userEvent.keyboard('[Delete]') + + expect(element).toHaveTextContent('faz') +}) diff --git a/tests/keyboard/plugin/functional.ts b/tests/keyboard/plugin/functional.ts index 0a2ba929..283df71a 100644 --- a/tests/keyboard/plugin/functional.ts +++ b/tests/keyboard/plugin/functional.ts @@ -226,8 +226,8 @@ test('tab through elements', () => { userEvent.keyboard('[Tab]') expect(elements[1]).toHaveFocus() - expect(getUISelection(elements[1])).toHaveProperty('selectionStart', 0) - expect(getUISelection(elements[1])).toHaveProperty('selectionEnd', 3) + expect(getUISelection(elements[1])).toHaveProperty('startOffset', 0) + expect(getUISelection(elements[1])).toHaveProperty('endOffset', 3) userEvent.keyboard('[Tab]') @@ -244,6 +244,6 @@ test('tab through elements', () => { userEvent.keyboard('[ShiftRight>][Tab]') expect(elements[1]).toHaveFocus() - expect(getUISelection(elements[1])).toHaveProperty('selectionStart', 0) - expect(getUISelection(elements[1])).toHaveProperty('selectionEnd', 3) + expect(getUISelection(elements[1])).toHaveProperty('startOffset', 0) + expect(getUISelection(elements[1])).toHaveProperty('endOffset', 3) }) diff --git a/tests/paste.js b/tests/paste.js index f4a0fc21..b792813f 100644 --- a/tests/paste.js +++ b/tests/paste.js @@ -7,6 +7,7 @@ test('should paste text in input', () => { const text = 'Hello, world!' userEvent.paste(element, text) expect(element).toHaveValue(text) + expect(element).toHaveProperty('selectionStart', 13) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: input[value="Hello, world!"] @@ -14,8 +15,6 @@ test('should paste text in input', () => { input[value=""] - focusin input[value=""] - paste input[value="Hello, world!"] - input - "{CURSOR}" -> "Hello, world!{CURSOR}" - input[value="Hello, world!"] - select `) }) @@ -25,6 +24,7 @@ test('should paste text in textarea', () => { const text = 'Hello, world!' userEvent.paste(element, text) expect(element).toHaveValue(text) + expect(element).toHaveProperty('selectionStart', 13) expect(getEventSnapshot()).toMatchInlineSnapshot(` Events fired on: textarea[value="Hello, world!"] @@ -32,8 +32,6 @@ test('should paste text in textarea', () => { textarea[value=""] - focusin textarea[value=""] - paste textarea[value="Hello, world!"] - input - "{CURSOR}" -> "Hello, world!{CURSOR}" - textarea[value="Hello, world!"] - select `) }) diff --git a/tests/pointer/index.ts b/tests/pointer/index.ts index 4fedaa1d..0b56b875 100644 --- a/tests/pointer/index.ts +++ b/tests/pointer/index.ts @@ -431,6 +431,20 @@ describe('mousedown moves selection', () => { expect(element).toHaveProperty('selectionStart', 11) }) + test('single click moves cursor to the last text', () => { + const {element} = setup( + `
foo bar baz
`, + ) + + userEvent.pointer({keys: '[MouseLeft]', target: element}) + + expect(document.getSelection()).toHaveProperty( + 'focusNode', + element.firstChild, + ) + expect(document.getSelection()).toHaveProperty('focusOffset', 11) + }) + test('double click selects a word or a sequence of whitespace', () => { const {element} = setup(``) @@ -521,6 +535,11 @@ describe('mousedown moves selection', () => { expect(element).toHaveProperty('selectionStart', 4) expect(element).toHaveProperty('selectionEnd', 11) + + userEvent.pointer({offset: 5}, {pointerState}) + + expect(element).toHaveProperty('selectionStart', 4) + expect(element).toHaveProperty('selectionEnd', 7) }) test('selection is moved on non-input elements', () => { diff --git a/tests/utils/focus/selection.ts b/tests/utils/focus/selection.ts new file mode 100644 index 00000000..d76fb01d --- /dev/null +++ b/tests/utils/focus/selection.ts @@ -0,0 +1,185 @@ +import { + getInputRange, + focus, + setSelection, + setSelectionRange, + modifySelection, +} from '#src/utils' +import {setup} from '#testHelpers/utils' + +test('range on input', () => { + const {element} = setup('') + + expect(getInputRange(element)).toHaveProperty('startOffset', 0) + expect(getInputRange(element)).toHaveProperty('endOffset', 0) + + setSelection({ + focusNode: element, + anchorOffset: 1, + focusOffset: 2, + }) + + expect(element).toHaveProperty('selectionStart', 1) + expect(element).toHaveProperty('selectionEnd', 2) + expect(getInputRange(element)).toHaveProperty('startOffset', 1) + expect(getInputRange(element)).toHaveProperty('endOffset', 2) + + setSelectionRange(element, 2, 3) + + expect(element).toHaveProperty('selectionStart', 2) + expect(element).toHaveProperty('selectionEnd', 3) + expect(getInputRange(element)).toHaveProperty('startOffset', 2) + expect(getInputRange(element)).toHaveProperty('endOffset', 3) +}) + +test('range on contenteditable', () => { + const {element} = setup('
foo
') + + expect(getInputRange(element)).toBe(undefined) + + setSelection({ + focusNode: element, + anchorOffset: 0, + focusOffset: 1, + }) + + const inputRangeA = getInputRange(element) + expect(inputRangeA).toHaveProperty('startContainer', element) + expect(inputRangeA).toHaveProperty('startOffset', 0) + expect(inputRangeA).toHaveProperty('endContainer', element) + expect(inputRangeA).toHaveProperty('endOffset', 1) + + setSelectionRange(element, 3, 2) + + const inputRangeB = getInputRange(element) + expect(inputRangeB).toHaveProperty('startContainer', element.firstChild) + expect(inputRangeB).toHaveProperty('startOffset', 2) + expect(inputRangeB).toHaveProperty('endContainer', element.firstChild) + expect(inputRangeB).toHaveProperty('endOffset', 3) + expect(document.getSelection()).toHaveProperty('anchorOffset', 3) + expect(document.getSelection()).toHaveProperty('focusOffset', 2) +}) + +test('range on input without selection support', () => { + const {element} = setup(``) + + expect(getInputRange(element)).toHaveProperty('startOffset', 0) + expect(getInputRange(element)).toHaveProperty('endOffset', 0) + + setSelectionRange(element, 1, 2) + + expect(getInputRange(element)).toHaveProperty('startOffset', 1) + expect(getInputRange(element)).toHaveProperty('endOffset', 2) +}) + +describe('modify selection', () => { + test('extend selection on input element', () => { + const {element} = setup(``) + + setSelection({focusNode: element, focusOffset: 5}) + + modifySelection({focusNode: element, focusOffset: 1}) + + expect(element).toHaveProperty('selectionStart', 1) + expect(element).toHaveProperty('selectionEnd', 5) + + modifySelection({focusNode: element, focusOffset: 9}) + + expect(element).toHaveProperty('selectionStart', 5) + expect(element).toHaveProperty('selectionEnd', 9) + }) + + test('extend selection on other nodes', () => { + const {element} = setup(`
foo bar baz
`) + const text = element.firstChild as Text + + setSelection({focusNode: text, focusOffset: 5}) + + modifySelection({focusNode: text, focusOffset: 1}) + + expect(document.getSelection()).toHaveProperty('focusOffset', 1) + expect(document.getSelection()).toHaveProperty('anchorOffset', 5) + expect(document.getSelection()?.toString()).toBe('oo b') + + modifySelection({focusNode: text, focusOffset: 9}) + + expect(document.getSelection()).toHaveProperty('anchorOffset', 5) + expect(document.getSelection()).toHaveProperty('focusOffset', 9) + expect(document.getSelection()?.toString()).toBe('ar b') + }) +}) + +describe('update selection when moving focus into element with own selection implementation', () => { + test('replace selection', () => { + const {element} = setup(`
foo
`) + const text = element.childNodes.item(0) as Text + const input = element.childNodes.item(1) as HTMLInputElement + + setSelection({focusNode: text, focusOffset: 1}) + expect(document.getSelection()).toHaveProperty('focusNode', text) + + focus(input) + expect(document.getSelection()).toHaveProperty('anchorNode', input) + expect(document.getSelection()).toHaveProperty('anchorOffset', 0) + expect(document.getSelection()).toHaveProperty('focusNode', input) + expect(document.getSelection()).toHaveProperty('focusOffset', 0) + }) + + test('retain cursor position in contenteditable', () => { + const {element} = setup(`
foo
`) + const text = element.childNodes.item(0) as Text + const input = element.childNodes.item(1) as HTMLInputElement + + setSelection({focusNode: text, focusOffset: 1}) + expect(document.getSelection()).toHaveProperty('focusNode', text) + expect(document.getSelection()).toHaveProperty('focusOffset', 1) + + focus(input) + expect(document.getSelection()).toHaveProperty('anchorNode', text) + expect(document.getSelection()).toHaveProperty('anchorOffset', 1) + expect(document.getSelection()).toHaveProperty('focusNode', text) + expect(document.getSelection()).toHaveProperty('focusOffset', 1) + }) + + test('replace extended selection in contenteditable with cursor in first text', () => { + const {element} = setup(`
foo
`) + const text = element.childNodes.item(0) as Text + const input = element.childNodes.item(1) as HTMLInputElement + + setSelection({ + anchorNode: text, + anchorOffset: 1, + focusNode: text, + focusOffset: 2, + }) + expect(document.getSelection()).toHaveProperty('focusNode', text) + expect(document.getSelection()).toHaveProperty('focusOffset', 2) + + focus(input) + expect(document.getSelection()).toHaveProperty('anchorNode', text) + expect(document.getSelection()).toHaveProperty('anchorOffset', 0) + expect(document.getSelection()).toHaveProperty('focusNode', text) + expect(document.getSelection()).toHaveProperty('focusOffset', 0) + }) + + test('replace extended selection in contenteditable with cursor at start', () => { + const {element} = setup(`
foo
`) + const text = element.childNodes.item(1) as Text + const input = element.childNodes.item(2) as HTMLInputElement + + setSelection({ + anchorNode: text, + anchorOffset: 1, + focusNode: text, + focusOffset: 2, + }) + expect(document.getSelection()).toHaveProperty('focusNode', text) + expect(document.getSelection()).toHaveProperty('focusOffset', 2) + + focus(input) + expect(document.getSelection()).toHaveProperty('anchorNode', element) + expect(document.getSelection()).toHaveProperty('anchorOffset', 0) + expect(document.getSelection()).toHaveProperty('focusNode', element) + expect(document.getSelection()).toHaveProperty('focusOffset', 0) + }) +})