Skip to content

Commit

Permalink
feat: keep track of document state in UI (#747)
Browse files Browse the repository at this point in the history
* feat: keep track of document state in UI

* programmatically changing value resets UIValue

* prevent stacking of value interceptors

* programmatically changing value resets initial value

* fix istanbul ignore

see kentcdodds/kcd-scripts#218

* ignore uncovered `activeElement` being `null`

* intercept calls to `setSelectionRange`

* fix istanbul ignore

see kentcdodds/kcd-scripts#218

* ignore omitting unnecessary event

* remove obsolete util

* move modules

* fix istanbul ignore

see kentcdodds/kcd-scripts#218

* ignore omitting unnecessary event
  • Loading branch information
ph-fritsche committed Oct 19, 2021
1 parent 523bc1d commit 9a17358
Show file tree
Hide file tree
Showing 24 changed files with 524 additions and 283 deletions.
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)
})
File renamed without changes.
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

0 comments on commit 9a17358

Please sign in to comment.