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'),
+ )
+ })
+})