Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: keep track of document state in UI #747

Merged
merged 14 commits into from
Oct 19, 2021
Merged
94 changes: 94 additions & 0 deletions src/__tests__/document/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {setup} from '../helpers/utils'
import {
prepareDocument,
getUIValue,
setUIValue,
getUISelection,
setUISelection,
} from '../../document'

function prepare(element: Element) {
prepareDocument(element.ownerDocument)
// safe to call multiple times
prepareDocument(element.ownerDocument)
prepareDocument(element.ownerDocument)
}

test('keep track of value in UI', () => {
const {element} = setup<HTMLInputElement>(`<input type="number"/>`)
// The element has to either receive focus or be already focused when preparing.
element.focus()

prepare(element)

setUIValue(element, '2e-')

expect(element).toHaveValue(null)
expect(getUIValue(element)).toBe('2e-')

element.value = '3'

expect(element).toHaveValue(3)
expect(getUIValue(element)).toBe('3')
})

test('trigger `change` event if value changed since focus/set', () => {
const {element, getEvents} = setup<HTMLInputElement>(`<input type="number"/>`)

prepare(element)

element.focus()
// Invalid value is equal to empty
setUIValue(element, '2e-')
element.blur()

expect(getEvents('change')).toHaveLength(0)

element.focus()
// Programmatically changing value sets initial value
element.value = '3'
setUIValue(element, '3')
element.blur()

expect(getEvents('change')).toHaveLength(0)

element.focus()
element.value = '2'
setUIValue(element, '3')
element.blur()

expect(getEvents('change')).toHaveLength(1)
})

test('maintain selection range like UI', () => {
const {element} = setup<HTMLInputElement>(`<input type="text" value="abc"/>`)

prepare(element)

element.setSelectionRange(1, 1)
element.focus()
setUIValue(element, 'adbc')
setUISelection(element, 2, 2)

expect(getUISelection(element)).toEqual({
selectionStart: 2,
selectionEnd: 2,
})
expect(element.selectionStart).toBe(2)
})

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

prepare(element)

element.focus()
setUIValue(element, '2e-')
setUISelection(element, 2, 2)

expect(getUISelection(element)).toEqual({
selectionStart: 2,
selectionEnd: 2,
})
expect(element.selectionStart).toBe(null)
})
17 changes: 10 additions & 7 deletions src/__tests__/keyboard/plugin/character.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {getUIValue} from 'document/value'
import userEvent from 'index'
import {setup} from '__tests__/helpers/utils'

Expand All @@ -24,21 +25,23 @@ test('type [Enter] in contenteditable', () => {
})

test.each([
['1e--5', 1e-5, undefined, 4],
['1e--5', 1e-5, '1e-5', 4],
['1--e--5', null, '1--e5', 5],
['.-1.-e--5', null, '.-1-e5', 6],
['1.5e--5', 1.5e-5, undefined, 6],
['1e5-', 1e5, undefined, 3],
['1.5e--5', 1.5e-5, '1.5e-5', 6],
['1e5-', 1e5, '1e5', 3],
])(
'type invalid values into <input type="number"/>',
(text, expectedValue, expectedCarryValue, expectedInputEvents) => {
const {element, getEvents} = setup(`<input type="number"/>`)
(text, expectedValue, expectedUiValue, expectedInputEvents) => {
const {element, getEvents} = setup<HTMLInputElement>(
`<input type="number"/>`,
)
element.focus()

const state = userEvent.keyboard(text)
userEvent.keyboard(text)

expect(element).toHaveValue(expectedValue)
expect(state).toHaveProperty('carryValue', expectedCarryValue)
expect(getUIValue(element)).toBe(expectedUiValue)
expect(getEvents('input')).toHaveLength(expectedInputEvents)
},
)
5 changes: 1 addition & 4 deletions src/__tests__/type.js
Original file line number Diff line number Diff line change
Expand Up @@ -1503,10 +1503,7 @@ describe('promise rejections', () => {
console.error.mockReset()
})

test.each([
['foo', '[{', 'Unable to find the "window"'],
[document.body, '[{', 'Expected key descriptor but found "{"'],
])(
test.each([[document.body, '[{', 'Expected key descriptor but found "{"']])(
'catch promise rejections and report to the console on synchronous calls',
async (element, text, errorMessage) => {
const errLog = jest
Expand Down
28 changes: 28 additions & 0 deletions src/document/applyNative.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* React tracks the changes on element properties.
* This workaround tries to alter the DOM element without React noticing,
* so that it later picks up the change.
*
* @see https://github.com/facebook/react/blob/148f8e497c7d37a3c7ab99f01dec2692427272b1/packages/react-dom/src/client/inputValueTracking.js#L51-L104
*/
export function applyNative<T extends Element, P extends keyof T>(
element: T,
propName: P,
propValue: T[P],
) {
const descriptor = Object.getOwnPropertyDescriptor(element, propName)
const nativeDescriptor = Object.getOwnPropertyDescriptor(
element.constructor.prototype,
propName,
)

if (descriptor && nativeDescriptor) {
Object.defineProperty(element, propName, nativeDescriptor)
}

element[propName] = propValue

if (descriptor) {
Object.defineProperty(element, propName, descriptor)
}
}
79 changes: 79 additions & 0 deletions src/document/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {fireEvent} from '@testing-library/dom'
import {prepareSelectionInterceptor} from './selection'
import {
getInitialValue,
prepareValueInterceptor,
setInitialValue,
} from './value'

const isPrepared = Symbol('Node prepared with document state workarounds')

declare global {
interface Node {
[isPrepared]?: typeof isPrepared
}
}

export function prepareDocument(document: Document) {
if (document[isPrepared]) {
return
}

document.addEventListener(
'focus',
e => {
const el = e.target as Node

prepareElement(el)
},
{
capture: true,
passive: true,
},
)

// Our test environment defaults to `document.body` as `activeElement`.
// In other environments this might be `null` when preparing.
// istanbul ignore else
if (document.activeElement) {
prepareElement(document.activeElement)
}

document.addEventListener(
'blur',
e => {
const el = e.target as HTMLInputElement
const initialValue = getInitialValue(el)
if (typeof initialValue === 'string' && el.value !== initialValue) {
fireEvent.change(el)
}
},
{
capture: true,
passive: true,
},
)

document[isPrepared] = isPrepared
}

function prepareElement(el: Node | HTMLInputElement) {
if ('value' in el) {
setInitialValue(el)
}

if (el[isPrepared]) {
return
}

if ('value' in el) {
prepareValueInterceptor(el)
prepareSelectionInterceptor(el)
}

el[isPrepared] = isPrepared
}

export {applyNative} from './applyNative'
export {getUIValue, setUIValue} from './value'
export {getUISelection, hasUISelection, setUISelection} from './selection'
58 changes: 58 additions & 0 deletions src/document/interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const Interceptor = Symbol('Interceptor for programmatical calls')

interface Interceptable {
[Interceptor]?: typeof Interceptor
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type anyFunc = (...a: any[]) => any
type Params<Prop> = Prop extends anyFunc ? Parameters<Prop> : [Prop]
type ImplReturn<Prop> = Prop extends anyFunc ? Parameters<Prop> : Prop

export function prepareInterceptor<
ElementType extends Element,
PropName extends keyof ElementType,
>(
element: ElementType,
propName: PropName,
interceptorImpl: (
this: ElementType,
...args: Params<ElementType[PropName]>
) => ImplReturn<ElementType[PropName]>,
) {
const prototypeDescriptor = Object.getOwnPropertyDescriptor(
element.constructor.prototype,
propName,
)

const target = prototypeDescriptor?.set ? 'set' : 'value'
if (
typeof prototypeDescriptor?.[target] !== 'function' ||
(prototypeDescriptor[target] as Interceptable)[Interceptor]
) {
return
}

const realFunc = prototypeDescriptor[target] as (
this: ElementType,
...args: unknown[]
) => unknown
function intercept(
this: ElementType,
...args: Params<ElementType[PropName]>
) {
const realArgs = interceptorImpl.call(this, ...args)

if (target === 'set') {
realFunc.call(this, realArgs)
} else {
realFunc.call(this, ...realArgs)
}
}
;(intercept as Interceptable)[Interceptor] = Interceptor

Object.defineProperty(element.constructor.prototype, propName, {
...prototypeDescriptor,
[target]: intercept,
})
}
86 changes: 86 additions & 0 deletions src/document/selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {prepareInterceptor} from './interceptor'

const UISelection = Symbol('Displayed selection in UI')

interface Value extends Number {
[UISelection]?: typeof UISelection
}

declare global {
interface Element {
[UISelection]?: {start: number; end: number}
}
}

function setSelectionInterceptor(
this: HTMLInputElement | HTMLTextAreaElement,
start: number | Value | null,
end: number | null,
direction: 'forward' | 'backward' | 'none' = 'none',
) {
const isUI = start && typeof start === 'object' && start[UISelection]

this[UISelection] = isUI
? {start: start.valueOf(), end: Number(end)}
: undefined

return [Number(start), end, direction] as Parameters<
HTMLInputElement['setSelectionRange']
>
}

export function prepareSelectionInterceptor(
element: HTMLInputElement | HTMLTextAreaElement,
) {
prepareInterceptor(element, 'setSelectionRange', setSelectionInterceptor)
}

export function setUISelection(
element: HTMLInputElement | HTMLTextAreaElement,
start: number,
end: number,
) {
element[UISelection] = {start, end}

if (element.selectionStart === start && element.selectionEnd === end) {
return
}

// eslint-disable-next-line no-new-wrappers
const startObj = new Number(start)
;(startObj as Value)[UISelection] = UISelection

try {
element.setSelectionRange(startObj as number, end)
} catch {
// DOMException for invalid state is expected when calling this
// on an element without support for setSelectionRange
}
}

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,
}
}

export function clearUISelection(
element: HTMLInputElement | HTMLTextAreaElement,
) {
element[UISelection] = undefined
}

export function hasUISelection(
element: HTMLInputElement | HTMLTextAreaElement,
) {
return Boolean(element[UISelection])
}
Loading