diff --git a/src/clear.d.ts b/src/clear.d.ts deleted file mode 100644 index 7c3e18ca..00000000 --- a/src/clear.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare function clear(element: Element): void diff --git a/src/clear.js b/src/clear.js deleted file mode 100644 index 965d1a6c..00000000 --- a/src/clear.js +++ /dev/null @@ -1,30 +0,0 @@ -import {type} from './type' - -function clear(element) { - if (element.tagName !== 'INPUT' && element.tagName !== 'TEXTAREA') { - // TODO: support contenteditable - throw new Error( - 'clear currently only supports input and textarea elements.', - ) - } - - if (element.disabled) return - // TODO: track the selection range ourselves so we don't have to do this input "type" trickery - // just like cypress does: https://github.com/cypress-io/cypress/blob/8d7f1a0bedc3c45a2ebf1ff50324b34129fdc683/packages/driver/src/dom/selection.ts#L16-L37 - const elementType = element.type - // type is a readonly property on textarea, so check if element is an input before trying to modify it - if (element.tagName === 'INPUT') { - // setSelectionRange is not supported on certain types of inputs, e.g. "number" or "email" - element.type = 'text' - } - type(element, '{selectall}{del}', { - delay: 0, - initialSelectionStart: element.selectionStart, - initialSelectionEnd: element.selectionEnd, - }) - if (element.tagName === 'INPUT') { - element.type = elementType - } -} - -export {clear} diff --git a/src/clear.ts b/src/clear.ts new file mode 100644 index 00000000..a8946e3f --- /dev/null +++ b/src/clear.ts @@ -0,0 +1,43 @@ +import {isDisabled, isInstanceOfElement} from './utils' +import {type} from './type' + +function clear(element: Element) { + if ( + !isInstanceOfElement(element, 'HTMLInputElement') && + !isInstanceOfElement(element, 'HTMLTextAreaElement') + ) { + // TODO: support contenteditable + throw new Error( + 'clear currently only supports input and textarea elements.', + ) + } + const el = element as HTMLInputElement | HTMLTextAreaElement + + if (isDisabled(el)) { + return + } + + // TODO: track the selection range ourselves so we don't have to do this input "type" trickery + // just like cypress does: https://github.com/cypress-io/cypress/blob/8d7f1a0bedc3c45a2ebf1ff50324b34129fdc683/packages/driver/src/dom/selection.ts#L16-L37 + + const elementType = el.type + + if (elementType !== 'textarea') { + // setSelectionRange is not supported on certain types of inputs, e.g. "number" or "email" + ;(element as HTMLInputElement).type = 'text' + } + + type(element, '{selectall}{del}', { + delay: 0, + initialSelectionStart: + el.selectionStart ?? /* istanbul ignore next */ undefined, + initialSelectionEnd: + el.selectionEnd ?? /* istanbul ignore next */ undefined, + }) + + if (elementType !== 'textarea') { + ;(el as HTMLInputElement).type = elementType + } +} + +export {clear} diff --git a/src/click.d.ts b/src/click.d.ts deleted file mode 100644 index cf9fffc1..00000000 --- a/src/click.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -export declare interface clickOptions { - skipHover?: boolean - clickCount?: number -} - -export declare function click( - element: Element, - init?: MouseEventInit, - options?: clickOptions, -): void - -export declare function dblClick(element: Element, init?: MouseEventInit): void diff --git a/src/click.js b/src/click.ts similarity index 63% rename from src/click.js rename to src/click.ts index e8fe535f..b54c1330 100644 --- a/src/click.js +++ b/src/click.ts @@ -3,12 +3,14 @@ import { getMouseEventOptions, isLabelWithInternallyDisabledControl, isFocusable, + isDisabled, + isInstanceOfElement, } from './utils' import {hover} from './hover' import {blur} from './blur' import {focus} from './focus' -function getPreviouslyFocusedElement(element) { +function getPreviouslyFocusedElement(element: Element) { const focusedElement = element.ownerDocument.activeElement const wasAnotherElementFocused = focusedElement && @@ -17,7 +19,16 @@ function getPreviouslyFocusedElement(element) { return wasAnotherElementFocused ? focusedElement : null } -function clickLabel(label, init, {clickCount}) { +export declare interface clickOptions { + skipHover?: boolean + clickCount?: number +} + +function clickLabel( + label: HTMLLabelElement, + init: MouseEventInit | undefined, + {clickCount}: clickOptions, +) { if (isLabelWithInternallyDisabledControl(label)) return fireEvent.pointerDown(label, init) @@ -34,7 +45,11 @@ function clickLabel(label, init, {clickCount}) { if (label.control) focus(label.control) } -function clickBooleanElement(element, init, clickCount) { +function clickBooleanElement( + element: HTMLInputElement, + init: MouseEventInit | undefined, + {clickCount}: clickOptions, +) { fireEvent.pointerDown(element, init) if (!element.disabled) { fireEvent.mouseDown( @@ -42,7 +57,7 @@ function clickBooleanElement(element, init, clickCount) { getMouseEventOptions('mousedown', init, clickCount), ) } - focus(element, init) + focus(element) fireEvent.pointerUp(element, init) if (!element.disabled) { fireEvent.mouseUp( @@ -53,10 +68,14 @@ function clickBooleanElement(element, init, clickCount) { } } -function clickElement(element, init, {clickCount}) { +function clickElement( + element: Element, + init: MouseEventInit | undefined, + {clickCount}: clickOptions, +) { const previousElement = getPreviouslyFocusedElement(element) fireEvent.pointerDown(element, init) - if (!element.disabled) { + if (!isDisabled(element)) { const continueDefaultHandling = fireEvent.mouseDown( element, getMouseEventOptions('mousedown', init, clickCount), @@ -64,25 +83,26 @@ function clickElement(element, init, {clickCount}) { if (continueDefaultHandling) { const closestFocusable = findClosest(element, isFocusable) if (previousElement && !closestFocusable) { - blur(previousElement, init) + blur(previousElement) } else if (closestFocusable) { - focus(closestFocusable, init) + focus(closestFocusable) } } } fireEvent.pointerUp(element, init) - if (!element.disabled) { + if (!isDisabled(element)) { fireEvent.mouseUp( element, getMouseEventOptions('mouseup', init, clickCount), ) fireEvent.click(element, getMouseEventOptions('click', init, clickCount)) const parentLabel = element.closest('label') - if (parentLabel?.control) focus(parentLabel.control, init) + if (parentLabel?.control) focus(parentLabel.control) } } -function findClosest(el, callback) { +function findClosest(element: Element, callback: (e: Element) => boolean) { + let el: Element | null = element do { if (callback(el)) { return el @@ -92,25 +112,28 @@ function findClosest(el, callback) { return undefined } -function click(element, init, {skipHover = false, clickCount = 0} = {}) { +function click( + element: Element, + init?: MouseEventInit, + {skipHover = false, clickCount = 0}: clickOptions = {}, +) { if (!skipHover) hover(element, init) - switch (element.tagName) { - case 'LABEL': - clickLabel(element, init, {clickCount}) - break - case 'INPUT': - if (element.type === 'checkbox' || element.type === 'radio') { - clickBooleanElement(element, init, {clickCount}) - } else { - clickElement(element, init, {clickCount}) - } - break - default: - clickElement(element, init, {clickCount}) + + if (isInstanceOfElement(element, 'HTMLLabelElement')) { + clickLabel(element as HTMLLabelElement, init, {clickCount}) + } else if (isInstanceOfElement(element, 'HTMLInputElement')) { + const el = element as HTMLInputElement + if (el.type === 'checkbox' || el.type === 'radio') { + clickBooleanElement(el, init, {clickCount}) + } else { + clickElement(el, init, {clickCount}) + } + } else { + clickElement(element, init, {clickCount}) } } -function dblClick(element, init) { +function dblClick(element: Element, init?: MouseEventInit) { hover(element, init) click(element, init, {skipHover: true, clickCount: 0}) click(element, init, {skipHover: true, clickCount: 1}) diff --git a/src/paste.d.ts b/src/paste.d.ts deleted file mode 100644 index 540c7c37..00000000 --- a/src/paste.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -export declare function paste( - element: Element, - text: string, - init?: MouseEventInit, - pasteOptions?: { - initialSelectionStart?: number - initialSelectionEnd?: number - }, -): void diff --git a/src/paste.js b/src/paste.ts similarity index 69% rename from src/paste.js rename to src/paste.ts index 30100e69..4dd340f5 100644 --- a/src/paste.js +++ b/src/paste.ts @@ -3,15 +3,25 @@ import { setSelectionRangeIfNecessary, calculateNewValue, eventWrapper, + isDisabled, } from './utils' +interface pasteOptions { + initialSelectionStart?: number + initialSelectionEnd?: number +} + function paste( - element, - text, - init, - {initialSelectionStart, initialSelectionEnd} = {}, + element: HTMLInputElement | HTMLTextAreaElement, + text: string, + init?: MouseEventInit, + {initialSelectionStart, initialSelectionEnd}: pasteOptions = {}, ) { - if (element.disabled) return + if (isDisabled(element)) { + return + } + + // TODO: implement for contenteditable if (typeof element.value === 'undefined') { throw new TypeError( `the current element is of type ${element.tagName} and doesn't have a valid value`, @@ -43,10 +53,16 @@ function paste( inputType: 'insertFromPaste', target: {value: newValue}, }) - setSelectionRangeIfNecessary(element, { - newSelectionStart, - newSelectionEnd: newSelectionStart, - }) + setSelectionRangeIfNecessary( + element, + + // TODO: investigate why the selection caused by invalid parameters was expected + ({ + newSelectionStart, + selectionEnd: newSelectionStart, + } as unknown) as number, + ({} as unknown) as number, + ) } } diff --git a/src/select-options.d.ts b/src/select-options.d.ts deleted file mode 100644 index 7cc0788d..00000000 --- a/src/select-options.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -export declare function selectOptions( - element: Element, - values: HTMLElement | HTMLElement[] | string[] | string, - init?: MouseEventInit, -): void - -export declare function deselectOptions( - element: Element, - values: HTMLElement | HTMLElement[] | string[] | string, - init?: MouseEventInit, -): void diff --git a/src/select-options.js b/src/select-options.ts similarity index 77% rename from src/select-options.js rename to src/select-options.ts index 9620814e..0bbd0eda 100644 --- a/src/select-options.js +++ b/src/select-options.ts @@ -1,11 +1,16 @@ import {createEvent, getConfig, fireEvent} from '@testing-library/dom' -import {isInstanceOfElement} from './utils' +import {isDisabled, isInstanceOfElement} from './utils' import {click} from './click' import {focus} from './focus' import {hover, unhover} from './hover' -function selectOptionsBase(newValue, select, values, init) { - if (!newValue && !select.multiple) { +function selectOptionsBase( + newValue: boolean, + select: Element, + values: HTMLElement | HTMLElement[] | string[] | string, + init?: MouseEventInit, +) { + if (!newValue && !(select as HTMLSelectElement).multiple) { throw getConfig().getElementError( `Unable to deselect an option in a non-multiple select. Use selectOptions to change the selection instead.`, select, @@ -17,28 +22,30 @@ function selectOptionsBase(newValue, select, values, init) { ) const selectedOptions = valArray .map(val => { - if (allOptions.includes(val)) { + if (typeof val !== 'string' && allOptions.includes(val)) { return val } else { const matchingOption = allOptions.find( - o => o.value === val || o.innerHTML === val, + o => + (o as HTMLInputElement | HTMLTextAreaElement).value === val || + o.innerHTML === val, ) if (matchingOption) { return matchingOption } else { throw getConfig().getElementError( - `Value "${val}" not found in options`, + `Value "${String(val)}" not found in options`, select, ) } } }) - .filter(option => !option.disabled) + .filter(option => !isDisabled(option)) - if (select.disabled || !selectedOptions.length) return + if (isDisabled(select) || !selectedOptions.length) return if (isInstanceOfElement(select, 'HTMLSelectElement')) { - if (select.multiple) { + if ((select as HTMLSelectElement).multiple) { for (const option of selectedOptions) { // events fired for multiple select are weird. Can't use hover... fireEvent.pointerOver(option, init) @@ -49,17 +56,17 @@ function selectOptionsBase(newValue, select, values, init) { fireEvent.mouseMove(option, init) fireEvent.pointerDown(option, init) fireEvent.mouseDown(option, init) - focus(select, init) + focus(select) fireEvent.pointerUp(option, init) fireEvent.mouseUp(option, init) - selectOption(option) + selectOption(option as HTMLOptionElement) fireEvent.click(option, init) } } else if (selectedOptions.length === 1) { // the click to open the select options click(select, init) - selectOption(selectedOptions[0]) + selectOption(selectedOptions[0] as HTMLOptionElement) // the browser triggers another click event on the select for the click on the option // this second click has no 'down' phase @@ -89,7 +96,7 @@ function selectOptionsBase(newValue, select, values, init) { ) } - function selectOption(option) { + function selectOption(option: HTMLOptionElement) { option.selected = newValue fireEvent( select, diff --git a/src/tab.d.ts b/src/tab.d.ts deleted file mode 100644 index 7fc01d1d..00000000 --- a/src/tab.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -interface tabOptions { - shift?: boolean - focusTrap?: Document | Element -} - -export declare function tab(userOpts?: tabOptions): void diff --git a/src/tab.js b/src/tab.ts similarity index 75% rename from src/tab.js rename to src/tab.ts index 975e99ca..95a11171 100644 --- a/src/tab.js +++ b/src/tab.ts @@ -1,9 +1,19 @@ import {fireEvent} from '@testing-library/dom' -import {getActiveElement, FOCUSABLE_SELECTOR, isVisible} from './utils' +import { + getActiveElement, + FOCUSABLE_SELECTOR, + isVisible, + isDisabled, +} from './utils' import {focus} from './focus' import {blur} from './blur' -function getNextElement(currentIndex, shift, elements, focusTrap) { +function getNextElement( + currentIndex: number, + shift: boolean, + elements: Element[], + focusTrap?: Document | Element, +) { if (focusTrap === document && currentIndex === 0 && shift) { return document.body } else if ( @@ -19,7 +29,12 @@ function getNextElement(currentIndex, shift, elements, focusTrap) { } } -function tab({shift = false, focusTrap} = {}) { +interface tabOptions { + shift?: boolean + focusTrap?: Document | Element +} + +function tab({shift = false, focusTrap}: tabOptions = {}) { const previousElement = getActiveElement(focusTrap?.ownerDocument ?? document) if (!focusTrap) { @@ -28,14 +43,13 @@ function tab({shift = false, focusTrap} = {}) { const focusableElements = focusTrap.querySelectorAll(FOCUSABLE_SELECTOR) - const enabledElements = [...focusableElements].filter( + const enabledElements = Array.from(focusableElements).filter( el => el === previousElement || (el.getAttribute('tabindex') !== '-1' && - !el.disabled && + !isDisabled(el) && // Hidden elements are not tabable - isVisible(el) - ), + isVisible(el)), ) if (enabledElements.length === 0) return @@ -51,8 +65,8 @@ function tab({shift = false, focusTrap} = {}) { return a.idx - b.idx } - const tabIndexA = a.el.getAttribute('tabindex') - const tabIndexB = b.el.getAttribute('tabindex') + const tabIndexA = Number(a.el.getAttribute('tabindex')) + const tabIndexB = Number(b.el.getAttribute('tabindex')) const diff = tabIndexA - tabIndexB @@ -60,20 +74,22 @@ function tab({shift = false, focusTrap} = {}) { }) .map(({el}) => el) - const checkedRadio = {} - let prunedElements = [] - orderedElements.forEach(el => { + // TODO: verify/remove type casts + + const checkedRadio: Record = {} + let prunedElements: HTMLInputElement[] = [] + orderedElements.forEach(currentElement => { // For radio groups keep only the active radio // If there is no active radio, keep only the checked radio // If there is no checked radio, treat like everything else + + const el = currentElement as HTMLInputElement + if (el.type === 'radio' && el.name) { // If the active element is part of the group, add only that - if ( - previousElement && - previousElement.type === el.type && - previousElement.name === el.name - ) { - if (el === previousElement) { + const prev = previousElement as HTMLInputElement | null + if (prev && prev.type === el.type && prev.name === el.name) { + if (el === prev) { prunedElements.push(el) } return @@ -90,7 +106,7 @@ function tab({shift = false, focusTrap} = {}) { } // If we already found the checked one, skip - if (checkedRadio[el.name]) { + if (typeof checkedRadio[el.name] !== 'undefined') { return } } diff --git a/src/upload.d.ts b/src/upload.d.ts deleted file mode 100644 index 3304a709..00000000 --- a/src/upload.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -interface uploadInit { - clickInit?: MouseEventInit - changeInit?: Event -} - -interface uploadOptions { - applyAccept?: boolean -} - -export declare function upload( - element: Element, - files: File | File[], - init?: uploadInit, - options?: uploadOptions, -): void diff --git a/src/upload.js b/src/upload.ts similarity index 59% rename from src/upload.js rename to src/upload.ts index 0d067c44..a799206c 100644 --- a/src/upload.js +++ b/src/upload.ts @@ -2,27 +2,44 @@ import {fireEvent, createEvent} from '@testing-library/dom' import {click} from './click' import {blur} from './blur' import {focus} from './focus' +import {isDisabled, isInstanceOfElement} from './utils' -function upload(element, fileOrFiles, init, {applyAccept = false} = {}) { - if (element.disabled) return +interface uploadInit { + clickInit?: MouseEventInit + changeInit?: Event +} + +interface uploadOptions { + applyAccept?: boolean +} - click(element, init) +function upload( + element: HTMLInputElement | HTMLLabelElement, + fileOrFiles: File | File[], + init?: uploadInit, + {applyAccept = false}: uploadOptions = {}, +) { + if (isDisabled(element)) return - const input = element.tagName === 'LABEL' ? element.control : element + click(element, init?.clickInit) + + const input = isInstanceOfElement(element, 'HTMLLabelElement') + ? ((element as HTMLLabelElement).control as HTMLInputElement) + : (element as HTMLInputElement) const files = (Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles]) - .filter(file => !applyAccept || isAcceptableFile(file, element.accept)) + .filter(file => !applyAccept || isAcceptableFile(file, input.accept)) .slice(0, input.multiple ? undefined : 1) // blur fires when the file selector pops up - blur(element, init) + blur(element) // focus fires when they make their selection - focus(element, init) + focus(element) // do not fire an input event if the file selection does not change if ( - files.length === input.files.length && - files.every((f, i) => f === input.files.item(i)) + files.length === input.files?.length && + files.every((f, i) => f === input.files?.item(i)) ) { return } @@ -30,10 +47,10 @@ function upload(element, fileOrFiles, init, {applyAccept = false} = {}) { // the event fired in the browser isn't actually an "input" or "change" event // but a new Event with a type set to "input" and "change" // Kinda odd... - const inputFiles = { - length: files.length, - item: index => files[index], + const inputFiles: FileList = { ...files, + length: files.length, + item: (index: number) => files[index], } fireEvent( @@ -53,7 +70,7 @@ function upload(element, fileOrFiles, init, {applyAccept = false} = {}) { }) } -function isAcceptableFile(file, accept) { +function isAcceptableFile(file: File, accept: string) { if (!accept) { return true } @@ -61,7 +78,7 @@ function isAcceptableFile(file, accept) { const wildcards = ['audio/*', 'image/*', 'video/*'] return accept.split(',').some(acceptToken => { - if (acceptToken[0] === '.') { + if (acceptToken.startsWith('.')) { // tokens starting with a dot represent a file extension return file.name.endsWith(acceptToken) } else if (wildcards.includes(acceptToken)) {