diff --git a/src/clipboard/copy.ts b/src/clipboard/copy.ts index dc4c5fe1..4c5b3054 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 {getActiveElementOrBody, writeDataTransferToClipboard} 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 f267cfd9..ec923004 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 {getActiveElementOrBody, writeDataTransferToClipboard} 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 ab2e98d5..aa21079c 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,7 +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' diff --git a/src/event/behavior/click.ts b/src/event/behavior/click.ts index c9d6a4b6..06a1c7c7 100644 --- a/src/event/behavior/click.ts +++ b/src/event/behavior/click.ts @@ -8,7 +8,7 @@ behavior.click = (event, target, instance) => { if (control) { return () => { if (isFocusable(control)) { - focusElement(control) + focusElement(control, true) } instance.dispatchEvent(control, cloneEvent(event)) } diff --git a/src/event/behavior/keydown.ts b/src/event/behavior/keydown.ts index 6927deef..8cd7306a 100644 --- a/src/event/behavior/keydown.ts +++ b/src/event/behavior/keydown.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/no-use-before-define */ -import {getUIValue, setUISelection, getValueOrTextContent} from '../../document' +import {getUIValue, getValueOrTextContent} from '../../document' import { getTabDestination, - hasOwnSelection, isContentEditable, isEditable, isElementType, @@ -106,13 +105,7 @@ const keydownBehavior: { target, instance.system.keyboard.modifiers.Shift, ) - focusElement(dest) - if (hasOwnSelection(dest)) { - setUISelection(dest, { - anchorOffset: 0, - focusOffset: dest.value.length, - }) - } + focusElement(dest, true) } }, } diff --git a/src/event/focus.ts b/src/event/focus.ts index d21b4e40..bba346a7 100644 --- a/src/event/focus.ts +++ b/src/event/focus.ts @@ -1,30 +1,72 @@ -import {findClosest, getActiveElement, isFocusable} from '../utils' +import {setUISelection} from '../document' +import { + delegatesFocus, + findClosest, + findFocusable, + getActiveElementOrBody, + hasOwnSelection, + isFocusable, + isFocusTarget, +} from '../utils' import {updateSelectionOnFocus} from './selection' import {wrapEvent} from './wrapEvent' /** * Focus closest focusable element. */ -export function focusElement(element: Element) { - const target = findClosest(element, isFocusable) +export function focusElement(element: Element, select: boolean = false) { + const target = findClosest(element, isFocusTarget) - const activeElement = getActiveElement(element.ownerDocument) + const activeElement = getActiveElementOrBody(element.ownerDocument) if ((target ?? element.ownerDocument.body) === activeElement) { return - } else if (target) { - wrapEvent(() => target.focus(), element) + } + + if (target) { + if (delegatesFocus(target)) { + const effectiveTarget = findFocusable(target.shadowRoot) + if (effectiveTarget) { + doFocus(effectiveTarget, true, element) + } else { + // This is not consistent across browsers if there is a focusable ancestor. + // Firefox falls back to the closest focusable ancestor + // of the shadow host as if `delegatesFocus` was `false`. + // Chrome falls back to `document.body`. + // We follow the minimal implementation of Chrome. + doBlur(activeElement, element) + } + } else { + doFocus(target, select, element) + } } else { - wrapEvent(() => (activeElement as HTMLElement | null)?.blur(), element) + doBlur(activeElement, element) } +} + +function doBlur(target: Element, source: Element) { + wrapEvent(() => (target as HTMLElement | null)?.blur(), source) +} + +function doFocus(target: HTMLElement, select: boolean, source: Element) { + wrapEvent(() => target.focus(), source) - updateSelectionOnFocus(target ?? element.ownerDocument.body) + if (hasOwnSelection(target)) { + if (select) { + setUISelection(target, { + anchorOffset: 0, + focusOffset: target.value.length, + }) + } + + updateSelectionOnFocus(target) + } } 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) + doBlur(element, element) } diff --git a/src/event/selection/updateSelectionOnFocus.ts b/src/event/selection/updateSelectionOnFocus.ts index 5d235aa1..ebc91034 100644 --- a/src/event/selection/updateSelectionOnFocus.ts +++ b/src/event/selection/updateSelectionOnFocus.ts @@ -1,10 +1,10 @@ -import {getContentEditable, hasOwnSelection} from '../../utils' +import {EditableInputOrTextarea, getContentEditable} from '../../utils' /** * Reset the Document Selection when moving focus into an element * with own selection implementation. */ -export function updateSelectionOnFocus(element: Element) { +export function updateSelectionOnFocus(element: EditableInputOrTextarea) { const selection = element.ownerDocument.getSelection() /* istanbul ignore if */ @@ -19,18 +19,16 @@ export function updateSelectionOnFocus(element: Element) { // 2) other selections will be replaced by a cursor // 2.a) at the start of the first child if it is a text node // 2.b) at the start of the contenteditable. - if (hasOwnSelection(element)) { - const contenteditable = getContentEditable(selection.focusNode) - if (contenteditable) { - if (!selection.isCollapsed) { - const focusNode = - contenteditable.firstChild?.nodeType === 3 - ? contenteditable.firstChild - : contenteditable - selection.setBaseAndExtent(focusNode, 0, focusNode, 0) - } - } else { - selection.setBaseAndExtent(element, 0, element, 0) + const contenteditable = getContentEditable(selection.focusNode) + if (contenteditable) { + if (!selection.isCollapsed) { + const focusNode = + contenteditable.firstChild?.nodeType === 3 + ? contenteditable.firstChild + : contenteditable + selection.setBaseAndExtent(focusNode, 0, focusNode, 0) } + } else { + selection.setBaseAndExtent(element, 0, element, 0) } } diff --git a/src/utils/focus/focusable.ts b/src/utils/focus/focusable.ts new file mode 100644 index 00000000..d287be06 --- /dev/null +++ b/src/utils/focus/focusable.ts @@ -0,0 +1,86 @@ +/** + * CSS selector to query focusable elements. + * + * This does not eliminate the following elements which are not focusable: + * - Custom elements with `tabindex` or `contenteditable` attribute + * - Shadow hosts with `delegatesFocus: true` + */ +export const FOCUSABLE_SELECTOR = [ + 'input:not([type=hidden]):not([disabled])', + 'button:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[contenteditable=""]', + '[contenteditable="true"]', + 'a[href]', + '[tabindex]:not([disabled])', +].join(', ') + +/** + * Determine if an element can be the target for `focusElement()`. + * + * This does not necessarily mean that this element will be the `activeElement`, + * as it might delegate focus into a shadow tree. + */ +export function isFocusTarget(element: Element): element is HTMLElement { + if (element.tagName.includes('-')) { + // custom elements without `delegatesFocus` are ignored + return delegatesFocus(element) + } + // elements without `delegatesFocus` behave normal even if they're a shadow host + return delegatesFocus(element) || element.matches(FOCUSABLE_SELECTOR) +} + +export function isFocusable(element: Element): element is HTMLElement { + return ( + !element.tagName.includes('-') && + !delegatesFocus(element) && + element.matches(FOCUSABLE_SELECTOR) + ) +} + +export function delegatesFocus( + element: Element, +): element is HTMLElement & {shadowRoot: ShadowRoot & {delegatesFocus: true}} { + // `delegatesFocus` is missing in Jsdom + // see https://github.com/jsdom/jsdom/issues/3418 + // We'll treat `undefined` as `true` + return ( + !!element.shadowRoot && + (element.shadowRoot.delegatesFocus as boolean | undefined) !== false + ) +} + +/** + * Find the first focusable element in a DOM tree. + */ +export function findFocusable( + element: Element | ShadowRoot, +): HTMLElement | undefined { + for (const el of Array.from(element.querySelectorAll('*'))) { + if (isFocusable(el)) { + return el + } else if (el.shadowRoot) { + const f = findFocusable(el.shadowRoot) + if (f) { + return f + } + } + } +} + +// TODO: use for tab +// /** +// * Find the all focusable elements in a DOM tree. +// */ +// export function findAllFocusable(element: Element | ShadowRoot): HTMLElement[] { +// const all: HTMLElement[] = [] +// for (const el of Array.from(element.querySelectorAll('*'))) { +// if (isFocusable(el)) { +// all.push(el) +// } else if (el.shadowRoot) { +// all.push(...findAllFocusable(el.shadowRoot)) +// } +// } +// return all +// } 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/src/utils/focus/getTabDestination.ts b/src/utils/focus/getTabDestination.ts index 6aa42c1b..c66dfd40 100644 --- a/src/utils/focus/getTabDestination.ts +++ b/src/utils/focus/getTabDestination.ts @@ -1,7 +1,7 @@ import {isDisabled} from '../misc/isDisabled' import {isElementType} from '../misc/isElementType' import {isVisible} from '../misc/isVisible' -import {FOCUSABLE_SELECTOR} from './selector' +import {FOCUSABLE_SELECTOR} from './focusable' export function getTabDestination(activeElement: Element, shift: boolean) { const document = activeElement.ownerDocument diff --git a/src/utils/focus/isFocusable.ts b/src/utils/focus/isFocusable.ts deleted file mode 100644 index ba1ce040..00000000 --- a/src/utils/focus/isFocusable.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {FOCUSABLE_SELECTOR} from './selector' - -export function isFocusable(element: Element): element is HTMLElement { - return element.matches(FOCUSABLE_SELECTOR) -} diff --git a/src/utils/focus/selector.ts b/src/utils/focus/selector.ts deleted file mode 100644 index 52d500fa..00000000 --- a/src/utils/focus/selector.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const FOCUSABLE_SELECTOR = [ - 'input:not([type=hidden]):not([disabled])', - 'button:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - '[contenteditable=""]', - '[contenteditable="true"]', - 'a[href]', - '[tabindex]:not([disabled])', -].join(', ') diff --git a/src/utils/index.ts b/src/utils/index.ts index 8ab15cc8..069d1d70 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -14,9 +14,8 @@ export * from './edit/setFiles' export * from './focus/cursor' export * from './focus/getActiveElement' export * from './focus/getTabDestination' -export * from './focus/isFocusable' +export * from './focus/focusable' export * from './focus/selection' -export * from './focus/selector' export * from './keyDef/readNextDescriptor' diff --git a/tests/_helpers/elements/hello-world.ts b/tests/_helpers/elements/hello-world.ts new file mode 100644 index 00000000..ddf72879 --- /dev/null +++ b/tests/_helpers/elements/hello-world.ts @@ -0,0 +1,9 @@ +export class HelloWorld extends HTMLElement { + constructor() { + super() + this.attachShadow({ + mode: 'open', + delegatesFocus: this.hasAttribute('delegates'), + }).innerHTML = `

Hello, World!

` + } +} diff --git a/tests/_helpers/elements/index.ts b/tests/_helpers/elements/index.ts new file mode 100644 index 00000000..aea20178 --- /dev/null +++ b/tests/_helpers/elements/index.ts @@ -0,0 +1,25 @@ +import {HelloWorld} from './hello-world' +import {ShadowInput} from './shadow-input' +import {ShadowHost} from './shadow-host' + +const customElements = { + 'hello-world': HelloWorld, + 'shadow-input': ShadowInput, + 'shadow-host': ShadowHost, +} + +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-host.ts b/tests/_helpers/elements/shadow-host.ts new file mode 100644 index 00000000..74b31c2b --- /dev/null +++ b/tests/_helpers/elements/shadow-host.ts @@ -0,0 +1,10 @@ +export class ShadowHost extends HTMLElement { + constructor() { + super() + + this.attachShadow({ + mode: 'open', + delegatesFocus: true, + }).innerHTML = String(this.getAttribute('innerHTML')) + } +} 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 a4c933c1..4e0ef39a 100644 --- a/tests/_helpers/index.ts +++ b/tests/_helpers/index.ts @@ -1,3 +1,6 @@ +import {CustomElements, registerCustomElements} from './elements' +import {toBeActive} from './toBeActive' + // this is pretty helpful: // https://codesandbox.io/s/quizzical-worker-eo909 @@ -11,5 +14,22 @@ expect.addSnapshotSerializer({ print: val => String((val)?.snapshot), }) +expect.extend({ + toBeActive, +}) +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-invalid-void-type + interface Matchers { + toBeActive: () => void + } + } +} + +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..f5ae4f06 100644 --- a/tests/_helpers/setup.ts +++ b/tests/_helpers/setup.ts @@ -1,7 +1,8 @@ +import {prettyDOM} from '@testing-library/dom' import {addListeners, EventHandlers} from './listeners' import userEvent from '#src' import {Options} from '#src/options' -import {FOCUSABLE_SELECTOR} from '#src/utils' +import {findFocusable, getActiveElementOrBody} from '#src/utils' import {setSelection} from '#src/event/selection' export function render( @@ -28,14 +29,14 @@ 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() + findFocusable(div)?.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, @@ -73,6 +71,8 @@ export function render( ), xpathNode: (xpath: string) => assertSingleNodeFromXPath(xpath, div) as NodeType, + query: ((...selectors: string[]) => + assertDeepQuery(div, ...selectors)) as deepQuery, } } @@ -91,6 +91,40 @@ function assertSingleNodeFromXPath(xpath: string, context: Node) { return node } +interface deepQuery { + ( + ...selectors: [...string[], K] + ): HTMLElementTagNameMap[K] + ( + ...selectors: [...string[], K] + ): SVGElementTagNameMap[K] + (...selectors: string[]): E +} +function assertDeepQuery(context: HTMLElement, ...selectors: string[]) { + let node: Element = context + for (let i = 0; i < selectors.length; i++) { + const container = node.shadowRoot?.mode === 'open' ? node.shadowRoot : node + const el = container.querySelector(selectors[i]) + if (!el) { + throw new Error( + [ + i > 0 && + `inside ${selectors + .slice(0, i) + .map(q => `"${q}"`) + .join(', ')}`, + `no element found for selector: "${selectors[i]}"`, + ...Array.from(container.children).map(e => prettyDOM(e)), + ] + .filter(Boolean) + .join('\n'), + ) + } + node = el + } + return node +} + export function setup( ui: string, { diff --git a/tests/_helpers/toBeActive.ts b/tests/_helpers/toBeActive.ts new file mode 100644 index 00000000..91f8192b --- /dev/null +++ b/tests/_helpers/toBeActive.ts @@ -0,0 +1,35 @@ +// .toHaveFocus() by `jest-dom` does not consider shadow DOM +// https://github.com/testing-library/jest-dom/blob/948d90f32cc79339bdeebea0454599db74c5d071/src/to-have-focus.js + +import {getActiveElementOrBody} from '#src/utils' + +export function toBeActive( + this: jest.MatcherContext, + element: Element, +): jest.CustomMatcherResult { + const active = getActiveElementOrBody(element.ownerDocument) + + return { + pass: active === element, + message: () => + [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeActive`, + 'element', + '', + ), + '', + ...(this.isNot + ? [ + 'Received element is focused:', + ` ${this.utils.printReceived(element)}`, + ] + : [ + 'Expected element with focus:', + ` ${this.utils.printExpected(element)}`, + 'Received element with focus:', + ` ${this.utils.printReceived(active)}`, + ]), + ].join('\n'), + } +} diff --git a/tests/clipboard/copy.ts b/tests/clipboard/copy.ts index 5306fd0a..7d3822c1 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 e99aa266..f1d47bed 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 c792ae1a..712b1131 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 37422449..00000000 --- a/tests/dom/customElement.ts +++ /dev/null @@ -1,86 +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'] - -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 af482eaf..e5bb9502 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' it('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/pointer/select.ts b/tests/pointer/select.ts index 3b54070d..83beccd9 100644 --- a/tests/pointer/select.ts +++ b/tests/pointer/select.ts @@ -16,6 +16,80 @@ test('single mousedown moves cursor to the end', async () => { expect(element).toHaveProperty('selectionStart', 11) }) +describe('move focus per mousedown in shadow DOM', () => { + test('into input element', async () => { + const {user, query} = setup( + ``, + {focus: '//button'}, + ) + const input = query('shadow-input', 'input') + + await user.pointer({keys: '[MouseLeft>]', target: input}) + + expect(input).toBeActive() + expect(input).toHaveProperty('selectionStart', 3) + }) + + test('on element which delegatesFocus to input', async () => { + const {element, user, query} = setup( + ``, + {focus: '//button'}, + ) + const input = query('shadow-input', 'input') + + await user.pointer({keys: '[MouseLeft>]', target: element}) + + expect(input).toBeActive() + expect(input).toHaveProperty('selectionStart', 0) + expect(input).toHaveProperty('selectionEnd', 3) + }) + + test('per delegatesFocus into another shadow tree', async () => { + const {element, user, query} = setup( + ``, + {focus: '//button'}, + ) + const input = query('shadow-host', 'shadow-input', 'input') + + await user.pointer({keys: '[MouseLeft>]', target: element}) + + expect(input).toBeActive() + expect(input).toHaveProperty('selectionStart', 0) + expect(input).toHaveProperty('selectionEnd', 3) + }) + + test('per delegatesFocus to nothing', async () => { + const {element, user} = setup( + ``, + ) + + await user.pointer({keys: '[MouseLeft>]', target: element}) + + expect(document.body).toBeActive() + }) + + test('per delegatesFocus to nothing through another shadow host', async () => { + const {element, user} = setup( + ``, + {focus: '//button'}, + ) + + await user.pointer({keys: '[MouseLeft>]', target: element}) + + expect(document.body).toBeActive() + }) + + test('on element without delegatesFocus', async () => { + const {element, user} = setup( + ``, + ) + + await user.pointer({keys: '[MouseLeft>]', target: element}) + + expect(document.body).toBeActive() + }) +}) + test('move focus to closest focusable element', async () => { const {element, user} = setup(`
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'), + ) + }) +})