diff --git a/src/clipboard/copy.ts b/src/clipboard/copy.ts index fac54bb9..b51fff60 100644 --- a/src/clipboard/copy.ts +++ b/src/clipboard/copy.ts @@ -1,10 +1,10 @@ import {copySelection} from '../document' import {type Instance} from '../setup' -import {writeDataTransferToClipboard} from '../utils' +import {writeDataTransferToClipboard, getActiveElementOrBody} from '../utils' export async function copy(this: Instance) { const doc = this.config.document - const target = doc.activeElement ?? /* istanbul ignore next */ doc.body + const target = getActiveElementOrBody(doc) const clipboardData = copySelection(target) diff --git a/src/clipboard/cut.ts b/src/clipboard/cut.ts index 7e75e2ee..746e7e26 100644 --- a/src/clipboard/cut.ts +++ b/src/clipboard/cut.ts @@ -1,10 +1,10 @@ import {copySelection} from '../document' import {type Instance} from '../setup' -import {writeDataTransferToClipboard} from '../utils' +import {writeDataTransferToClipboard, getActiveElementOrBody} from '../utils' export async function cut(this: Instance) { const doc = this.config.document - const target = doc.activeElement ?? /* istanbul ignore next */ doc.body + const target = getActiveElementOrBody(doc) const clipboardData = copySelection(target) diff --git a/src/clipboard/paste.ts b/src/clipboard/paste.ts index 18c70cab..a0aedd20 100644 --- a/src/clipboard/paste.ts +++ b/src/clipboard/paste.ts @@ -1,6 +1,7 @@ import {type Instance} from '../setup' import { createDataTransfer, + getActiveElementOrBody, getWindow, readDataTransferFromClipboard, } from '../utils' @@ -10,8 +11,7 @@ export async function paste( clipboardData?: DataTransfer | string, ) { const doc = this.config.document - const target = doc.activeElement ?? /* istanbul ignore next */ doc.body - + const target = getActiveElementOrBody(doc) const dataTransfer: DataTransfer = (typeof clipboardData === 'string' ? getClipboardDataFromString(doc, clipboardData) diff --git a/src/event/focus.ts b/src/event/focus.ts index d21b4e40..e650978d 100644 --- a/src/event/focus.ts +++ b/src/event/focus.ts @@ -1,4 +1,4 @@ -import {findClosest, getActiveElement, isFocusable} from '../utils' +import {findClosest, getActiveElementOrBody, isFocusable} from '../utils' import {updateSelectionOnFocus} from './selection' import {wrapEvent} from './wrapEvent' @@ -8,7 +8,7 @@ import {wrapEvent} from './wrapEvent' export function focusElement(element: Element) { const target = findClosest(element, isFocusable) - const activeElement = getActiveElement(element.ownerDocument) + const activeElement = getActiveElementOrBody(element.ownerDocument) if ((target ?? element.ownerDocument.body) === activeElement) { return } else if (target) { @@ -23,7 +23,7 @@ export function focusElement(element: Element) { export function blurElement(element: Element) { if (!isFocusable(element)) return - const wasActive = getActiveElement(element.ownerDocument) === element + const wasActive = getActiveElementOrBody(element.ownerDocument) === element if (!wasActive) return wrapEvent(() => element.blur(), element) diff --git a/src/utils/focus/getActiveElement.ts b/src/utils/focus/getActiveElement.ts index 725c2524..5f7b0769 100644 --- a/src/utils/focus/getActiveElement.ts +++ b/src/utils/focus/getActiveElement.ts @@ -1,8 +1,6 @@ import {isDisabled} from '../misc/isDisabled' -export function getActiveElement( - document: Document | ShadowRoot, -): Element | null { +function getActiveElement(document: Document | ShadowRoot): Element | null { const activeElement = document.activeElement if (activeElement?.shadowRoot) { diff --git a/tests/_helpers/elements/index.ts b/tests/_helpers/elements/index.ts new file mode 100644 index 00000000..20015454 --- /dev/null +++ b/tests/_helpers/elements/index.ts @@ -0,0 +1,21 @@ +import {ShadowInput} from './shadow-input' + +const customElements = { + 'shadow-input': ShadowInput, +} + +export type CustomElements = { + [k in keyof typeof customElements]: typeof customElements[k] extends { + new (): infer T + } + ? T + : never +} + +export function registerCustomElements() { + Object.entries(customElements).forEach(([name, constructor]) => { + if (!globalThis.customElements.get(name)) { + globalThis.customElements.define(name, constructor) + } + }) +} diff --git a/tests/_helpers/elements/shadow-input.ts b/tests/_helpers/elements/shadow-input.ts new file mode 100644 index 00000000..1e454c6e --- /dev/null +++ b/tests/_helpers/elements/shadow-input.ts @@ -0,0 +1,42 @@ +const observed = ['value'] + +const template = document.createElement('template') +template.innerHTML = ` + +` + +export class ShadowInput extends HTMLElement { + private $input?: HTMLInputElement + + static getObservedAttributes() { + return observed + } + constructor() { + super() + + this.attachShadow({mode: 'open', delegatesFocus: true}) + if (this.shadowRoot) { + this.shadowRoot.appendChild(template.content.cloneNode(true)) + this.$input = this.shadowRoot.querySelector('input') as HTMLInputElement + } + observed.forEach(name => { + this.render(name, this.getAttribute(name)) + }) + } + attributeChangedCallback(name: string, oldVal: string, newVal: string) { + if (oldVal === newVal) return + this.render(name, newVal) + } + + render(name: string, value: string | null) { + if (value == null) { + this.$input?.removeAttribute(name) + } else { + this.$input?.setAttribute(name, value) + } + } + + public get value(): string { + return this.$input?.value ?? '' + } +} diff --git a/tests/_helpers/index.ts b/tests/_helpers/index.ts index 13c9b71c..13d293be 100644 --- a/tests/_helpers/index.ts +++ b/tests/_helpers/index.ts @@ -1,5 +1,21 @@ +import {CustomElements, registerCustomElements} from './elements' + // this is pretty helpful: // https://codesandbox.io/s/quizzical-worker-eo909 +expect.addSnapshotSerializer({ + test: (val: unknown) => + Boolean( + typeof val === 'object' + ? Object.prototype.hasOwnProperty.call(val, 'snapshot') + : false, + ), + print: val => String((val)?.snapshot), +}) + +registerCustomElements() + +export type {CustomElements} + export {render, setup} from './setup' export {addEventListener, addListeners} from './listeners' diff --git a/tests/_helpers/setup.ts b/tests/_helpers/setup.ts index e9ae6d9c..a87be5b0 100644 --- a/tests/_helpers/setup.ts +++ b/tests/_helpers/setup.ts @@ -1,7 +1,7 @@ import {addListeners, EventHandlers} from './listeners' import userEvent from '#src' import {Options} from '#src/options' -import {FOCUSABLE_SELECTOR} from '#src/utils' +import {FOCUSABLE_SELECTOR, getActiveElementOrBody} from '#src/utils' import {setSelection} from '#src/event/selection' export function render( @@ -28,14 +28,15 @@ export function render( if (typeof focus === 'string') { ;(assertSingleNodeFromXPath(focus, div) as HTMLElement).focus() } else if (focus !== false) { - ;(div.querySelector(FOCUSABLE_SELECTOR) as HTMLElement | undefined)?.focus() + const element: HTMLElement | null = findFocusable(div) + element?.focus() } if (selection) { const focusNode = typeof selection.focusNode === 'string' ? assertSingleNodeFromXPath(selection.focusNode, div) - : document.activeElement + : getActiveElementOrBody(document) const anchorNode = typeof selection.anchorNode === 'string' ? assertSingleNodeFromXPath(selection.anchorNode, div) @@ -43,9 +44,6 @@ export function render( const focusOffset = selection.focusOffset ?? 0 const anchorOffset = selection.anchorOffset ?? focusOffset - if (!focusNode || !anchorNode) { - throw new Error(`missing/invalid selection.focusNode`) - } setSelection({ focusNode, anchorNode, @@ -105,3 +103,16 @@ export function setup( ...render(ui, {eventHandlers, focus, selection}), } } +function findFocusable(container: Element | ShadowRoot): HTMLElement | null { + for (const el of Array.from(container.querySelectorAll('*'))) { + if (el.matches(FOCUSABLE_SELECTOR)) { + return el as HTMLElement + } else if (el.shadowRoot) { + const f = findFocusable(el.shadowRoot) + if (f) { + return f + } + } + } + return null +} diff --git a/tests/clipboard/copy.ts b/tests/clipboard/copy.ts index 1b0e3b11..8d42d2db 100644 --- a/tests/clipboard/copy.ts +++ b/tests/clipboard/copy.ts @@ -98,3 +98,15 @@ describe('without Clipboard API', () => { expect(dt?.getData('text/plain')).toBe('bar') }) }) + +describe('on shadow DOM', () => { + test('copy in an input element', async () => { + const {user} = setup('', { + selection: {anchorOffset: 0, focusOffset: 4}, + }) + + const data = await user.copy() + + expect(data?.getData('text')).toEqual('test') + }) +}) diff --git a/tests/clipboard/cut.ts b/tests/clipboard/cut.ts index b8c2fc45..5a887792 100644 --- a/tests/clipboard/cut.ts +++ b/tests/clipboard/cut.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {render, setup} from '#testHelpers' +import {CustomElements, render, setup} from '#testHelpers' test('cut selected value', async () => { const {getEvents, user} = setup( @@ -106,3 +106,18 @@ describe('without Clipboard API', () => { expect(dt?.getData('text/plain')).toBe('bar') }) }) +describe('on shadow DOM', () => { + test('cut in an input element', async () => { + const {element, user} = setup( + '', + { + selection: {anchorOffset: 0, focusOffset: 4}, + }, + ) + + const data = await user.cut() + + expect(data?.getData('text')).toEqual('test') + expect(element.value.length).toEqual(0) + }) +}) diff --git a/tests/clipboard/paste.ts b/tests/clipboard/paste.ts index cd5eb967..39e0b459 100644 --- a/tests/clipboard/paste.ts +++ b/tests/clipboard/paste.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {render, setup} from '#testHelpers' +import {CustomElements, render, setup} from '#testHelpers' import {createDataTransfer} from '#src/utils' test('paste with empty clipboard', async () => { @@ -149,3 +149,15 @@ describe('without Clipboard API', () => { expect(getEvents()).toHaveLength(0) }) }) + +describe('on shadow DOM', () => { + test('paste into an input element', async () => { + const {element, user} = setup( + '', + ) + + await user.paste('test') + + expect(element.value).toEqual('test') + }) +}) diff --git a/tests/dom/customElement.ts b/tests/dom/customElement.ts deleted file mode 100644 index beafce69..00000000 --- a/tests/dom/customElement.ts +++ /dev/null @@ -1,87 +0,0 @@ -import userEvent from '#src' -import {addListeners} from '#testHelpers' - -// It is unclear which part of our implementation is targeted with this test. -// Can this be removed? Is it sufficient? - -const observed = ['value'] -const HTMLElement = window.HTMLElement - -class CustomEl extends HTMLElement { - private $input: HTMLInputElement - - static getObservedAttributes() { - return observed - } - - constructor() { - super() - const shadowRoot = this.attachShadow({mode: 'open'}) - shadowRoot.innerHTML = `` - this.$input = shadowRoot.querySelector('input') as HTMLInputElement - } - - connectedCallback() { - observed.forEach(name => { - this.render(name, this.getAttribute(name)) - }) - } - - attributeChangedCallback(name: string, oldVal: string, newVal: string) { - if (oldVal === newVal) return - this.render(name, newVal) - } - - render(name: string, value: string | null) { - if (value == null) { - this.$input.removeAttribute(name) - } else { - this.$input.setAttribute(name, value) - } - } -} - -customElements.define('custom-el', CustomEl) - -test('types text inside custom element', async () => { - const element = document.createElement('custom-el') - document.body.append(element) - const inputEl = (element.shadowRoot as ShadowRoot).querySelector( - 'input', - ) as HTMLInputElement - const {getEventSnapshot} = addListeners(inputEl) - - await userEvent.type(inputEl, 'Sup') - expect(getEventSnapshot()).toMatchInlineSnapshot(` - Events fired on: input[value="Sup"] - - input[value=""] - pointerover - input[value=""] - pointerenter - input[value=""] - mouseover - input[value=""] - mouseenter - input[value=""] - pointermove - input[value=""] - mousemove - input[value=""] - pointerdown - input[value=""] - mousedown: primary - input[value=""] - focus - input[value=""] - focusin - input[value=""] - pointerup - input[value=""] - mouseup: primary - input[value=""] - click: primary - input[value=""] - keydown: S - input[value=""] - keypress: S - input[value=""] - beforeinput - input[value="S"] - input - input[value="S"] - keyup: S - input[value="S"] - keydown: u - input[value="S"] - keypress: u - input[value="S"] - beforeinput - input[value="Su"] - input - input[value="Su"] - keyup: u - input[value="Su"] - keydown: p - input[value="Su"] - keypress: p - input[value="Su"] - beforeinput - input[value="Sup"] - input - input[value="Sup"] - keyup: p - `) -}) diff --git a/tests/keyboard/index.ts b/tests/keyboard/index.ts index 32ec1133..275b19ea 100644 --- a/tests/keyboard/index.ts +++ b/tests/keyboard/index.ts @@ -1,5 +1,5 @@ import userEvent from '#src' -import {addListeners, render, setup} from '#testHelpers' +import {addListeners, CustomElements, render, setup} from '#testHelpers' test('type without focus', async () => { const {element, user} = setup('', {focus: false}) @@ -163,3 +163,15 @@ test('disabling activeElement moves action to HTMLBodyElement', async () => { body - keyup: c `) }) + +describe('on shadow DOM', () => { + test('type into an input element', async () => { + const {element, user} = setup( + '', + ) + + await user.keyboard('test') + + expect(element.value).toEqual('test') + }) +}) diff --git a/tests/utils/focus/getActiveElement.ts b/tests/utils/focus/getActiveElement.ts new file mode 100644 index 00000000..13d630ad --- /dev/null +++ b/tests/utils/focus/getActiveElement.ts @@ -0,0 +1,24 @@ +import {setup} from '#testHelpers' +import {getActiveElementOrBody} from '#src/utils' + +test('focused input element', async () => { + const {element} = setup('') + + expect(getActiveElementOrBody(document)).toBe(element) +}) + +test('default to body as active element', async () => { + const {element} = setup('', {focus: false}) + element.blur() + expect(getActiveElementOrBody(document)).toBe(document.body) +}) + +describe('on shadow DOM', () => { + test('get focused element inside shadow tree', async () => { + const {element} = setup('') + + expect(getActiveElementOrBody(document)).toBe( + element.shadowRoot?.querySelector('input'), + ) + }) +})