From f4242842c1ed51d2ec8042263bee7abdbafea21e Mon Sep 17 00:00:00 2001 From: Christian24 Date: Sun, 7 Aug 2022 17:34:48 +0000 Subject: [PATCH 01/35] Potentially fix paste --- src/clipboard/paste.ts | 4 ++-- tests/webcomponents/index.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tests/webcomponents/index.ts diff --git a/src/clipboard/paste.ts b/src/clipboard/paste.ts index 54f03381..0c911610 100644 --- a/src/clipboard/paste.ts +++ b/src/clipboard/paste.ts @@ -1,6 +1,7 @@ import {Config, Instance} from '../setup' import { createDataTransfer, + getActiveElement, 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 = getActiveElement(doc) ?? doc.activeElement ?? doc.body const dataTransfer: DataTransfer = (typeof clipboardData === 'string' ? getClipboardDataFromString(doc, clipboardData) diff --git a/tests/webcomponents/index.ts b/tests/webcomponents/index.ts new file mode 100644 index 00000000..b81cab17 --- /dev/null +++ b/tests/webcomponents/index.ts @@ -0,0 +1,36 @@ +import {setup} from '#testHelpers' + +const template = document.createElement('template') +template.innerHTML = ` + +` + +class ShadowInput extends HTMLElement { + constructor() { + super() + + this.attachShadow({mode: 'open', delegatesFocus: true}) + if (this.shadowRoot) { + this.shadowRoot.appendChild(template.content.cloneNode(true)) + } + } + + public get value(): string { + return this.shadowRoot?.querySelector('input')?.value ?? '' + } +} + +test('Render open shadow DOM element', async () => { + window.customElements.define('shadow-input', ShadowInput) + const {element, user} = setup('', { + focus: false, + }) + + const inputElement = element.shadowRoot?.querySelector('input') + if (inputElement) { + await user.click(inputElement) + } + await user.keyboard('test') + await user.paste('test') + expect((element as ShadowInput).value).toEqual('testtest') +}) From cb2b18192509f729b2d26453238ea2676befc0b7 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Mon, 8 Aug 2022 21:32:15 +0200 Subject: [PATCH 02/35] Fixed cut, paste and copy for open shadow dom --- src/clipboard/copy.ts | 8 ++- src/clipboard/cut.ts | 8 ++- tests/dom/customElement.ts | 94 +++++++++++++++++++++++++++++++++++- tests/webcomponents/index.ts | 36 -------------- 4 files changed, 105 insertions(+), 41 deletions(-) delete mode 100644 tests/webcomponents/index.ts diff --git a/src/clipboard/copy.ts b/src/clipboard/copy.ts index 475c2afc..84bda828 100644 --- a/src/clipboard/copy.ts +++ b/src/clipboard/copy.ts @@ -1,9 +1,13 @@ import {Config, Instance} from '../setup' -import {copySelection, writeDataTransferToClipboard} from '../utils' +import { + copySelection, + getActiveElement, + 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 = getActiveElement(doc) ?? doc.activeElement ?? doc.body const clipboardData = copySelection(target) diff --git a/src/clipboard/cut.ts b/src/clipboard/cut.ts index e20f7d1d..06c17821 100644 --- a/src/clipboard/cut.ts +++ b/src/clipboard/cut.ts @@ -1,9 +1,13 @@ import {Config, Instance} from '../setup' -import {copySelection, writeDataTransferToClipboard} from '../utils' +import { + copySelection, + getActiveElement, + 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 = getActiveElement(doc) ?? doc.activeElement ?? doc.body const clipboardData = copySelection(target) diff --git a/tests/dom/customElement.ts b/tests/dom/customElement.ts index 37422449..83bbcb5f 100644 --- a/tests/dom/customElement.ts +++ b/tests/dom/customElement.ts @@ -1,5 +1,6 @@ import userEvent from '#src' -import {addListeners} from '#testHelpers' +import {addListeners, setup} from '#testHelpers' +import {setUISelection} from '#src/document' // It is unclear which part of our implementation is targeted with this test. // Can this be removed? Is it sufficient? @@ -84,3 +85,94 @@ test('types text inside custom element', async () => { input[value="Sup"] - keyup: p `) }) + +const template = document.createElement('template') +template.innerHTML = ` + +` + +class ShadowInput extends HTMLElement { + constructor() { + super() + + this.attachShadow({mode: 'open', delegatesFocus: true}) + if (this.shadowRoot) { + this.shadowRoot.appendChild(template.content.cloneNode(true)) + } + } + + public get value(): string { + return this.shadowRoot?.querySelector('input')?.value ?? '' + } +} +window.customElements.define('shadow-input', ShadowInput) + +test('Render open shadow DOM element - type', async () => { + const {element, user} = setup('', { + focus: false, + }) + + const inputElement = element.shadowRoot?.querySelector('input') + if (inputElement) { + await user.click(inputElement) + } + await user.keyboard('test') + + expect((element as ShadowInput).value).toEqual('test') +}) + +test('Render open shadow DOM element - copy', async () => { + const {element, user} = setup('', { + focus: false, + }) + + const inputElement = element.shadowRoot?.querySelector('input') + if (inputElement) { + await user.click(inputElement) + } + await user.keyboard('test') + + if (inputElement) { + setUISelection(inputElement, {anchorOffset: 0, focusOffset: 4}) + } + + const data = await user.copy() + + expect(data?.getData('text')).toEqual('test') +}) + +test('Render open shadow DOM element - paste', async () => { + const {element, user} = setup('', { + focus: false, + }) + + const inputElement = element.shadowRoot?.querySelector('input') + if (inputElement) { + await user.click(inputElement) + } + + await user.paste('test') + + expect((element as ShadowInput).value).toEqual('test') +}) + +test('Render open shadow DOM element - cut', async () => { + const {element, user} = setup('', { + focus: false, + }) + + const inputElement = element.shadowRoot?.querySelector('input') + if (inputElement) { + await user.click(inputElement) + } + await user.keyboard('test') + + if (inputElement) { + setUISelection(inputElement, {anchorOffset: 0, focusOffset: 4}) + } + + const data = await user.cut() + + expect(data?.getData('text')).toEqual('test') + expect((element as ShadowInput).value.length).toEqual(0) +}) diff --git a/tests/webcomponents/index.ts b/tests/webcomponents/index.ts deleted file mode 100644 index b81cab17..00000000 --- a/tests/webcomponents/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import {setup} from '#testHelpers' - -const template = document.createElement('template') -template.innerHTML = ` - -` - -class ShadowInput extends HTMLElement { - constructor() { - super() - - this.attachShadow({mode: 'open', delegatesFocus: true}) - if (this.shadowRoot) { - this.shadowRoot.appendChild(template.content.cloneNode(true)) - } - } - - public get value(): string { - return this.shadowRoot?.querySelector('input')?.value ?? '' - } -} - -test('Render open shadow DOM element', async () => { - window.customElements.define('shadow-input', ShadowInput) - const {element, user} = setup('', { - focus: false, - }) - - const inputElement = element.shadowRoot?.querySelector('input') - if (inputElement) { - await user.click(inputElement) - } - await user.keyboard('test') - await user.paste('test') - expect((element as ShadowInput).value).toEqual('testtest') -}) From 9771c98e0307140d8fc41dcfdc173c729da07bd7 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Fri, 12 Aug 2022 19:36:31 +0200 Subject: [PATCH 03/35] Resolve conflicts --- src/clipboard/copy.ts | 7 ++----- src/clipboard/cut.ts | 7 ++----- src/clipboard/paste.ts | 3 +-- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/clipboard/copy.ts b/src/clipboard/copy.ts index 90ea32f8..ab03b160 100644 --- a/src/clipboard/copy.ts +++ b/src/clipboard/copy.ts @@ -1,12 +1,9 @@ import {copySelection} from '../document' import {Instance} from '../setup' -import { - getActiveElement, - writeDataTransferToClipboard, -} from '../utils' +import {getActiveElement, writeDataTransferToClipboard} from '../utils' export async function copy(this: Instance) { - const doc = this[Config].document + const doc = this.config.document const target = getActiveElement(doc) ?? doc.activeElement ?? doc.body const clipboardData = copySelection(target) diff --git a/src/clipboard/cut.ts b/src/clipboard/cut.ts index ba378234..83391f3f 100644 --- a/src/clipboard/cut.ts +++ b/src/clipboard/cut.ts @@ -1,12 +1,9 @@ import {copySelection} from '../document' import type {Instance} from '../setup' -import { - getActiveElement, - writeDataTransferToClipboard, -} from '../utils' +import {getActiveElement, writeDataTransferToClipboard} from '../utils' export async function cut(this: Instance) { - const doc = this[Config].document + const doc = this.config.document const target = getActiveElement(doc) ?? doc.activeElement ?? doc.body const clipboardData = copySelection(target) diff --git a/src/clipboard/paste.ts b/src/clipboard/paste.ts index 05219835..88fa666b 100644 --- a/src/clipboard/paste.ts +++ b/src/clipboard/paste.ts @@ -10,8 +10,7 @@ export async function paste( this: Instance, clipboardData?: DataTransfer | string, ) { - - const doc = this[Config].document + const doc = this.config.document const target = getActiveElement(doc) ?? doc.activeElement ?? doc.body const dataTransfer: DataTransfer = From 79cb759501efdb226896e58866aa27b1a202d937 Mon Sep 17 00:00:00 2001 From: Christian24 Date: Sun, 14 Aug 2022 13:09:18 +0200 Subject: [PATCH 04/35] Update tests/dom/customElement.ts Co-authored-by: Philipp Fritsche --- tests/dom/customElement.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/dom/customElement.ts b/tests/dom/customElement.ts index 83bbcb5f..40cdd4df 100644 --- a/tests/dom/customElement.ts +++ b/tests/dom/customElement.ts @@ -121,7 +121,8 @@ test('Render open shadow DOM element - type', async () => { expect((element as ShadowInput).value).toEqual('test') }) -test('Render open shadow DOM element - copy', async () => { +describe('on shadow DOM', () => { + test('copy', async () => { const {element, user} = setup('', { focus: false, }) From 1d4dd4bdaf9d92e32ad61cc000760e0b93417805 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Sun, 14 Aug 2022 15:50:21 +0200 Subject: [PATCH 05/35] Moved Shadow DOM tests. Added focus and selection support for shadow DOM in setup(). Removed old custom elements test --- src/clipboard/copy.ts | 6 +- src/clipboard/cut.ts | 6 +- src/clipboard/paste.ts | 6 +- tests/_helpers/setup.ts | 14 +-- tests/_helpers/shadow-input.ts | 52 ++++++++++ tests/clipboard/copy.ts | 13 +++ tests/clipboard/cut.ts | 17 ++++ tests/clipboard/paste.ts | 12 +++ tests/dom/customElement.ts | 179 --------------------------------- tests/utility/type.ts | 17 ++++ 10 files changed, 128 insertions(+), 194 deletions(-) create mode 100644 tests/_helpers/shadow-input.ts delete mode 100644 tests/dom/customElement.ts diff --git a/src/clipboard/copy.ts b/src/clipboard/copy.ts index ab03b160..ca59c4ed 100644 --- a/src/clipboard/copy.ts +++ b/src/clipboard/copy.ts @@ -1,10 +1,10 @@ import {copySelection} from '../document' import {Instance} from '../setup' -import {getActiveElement, writeDataTransferToClipboard} from '../utils' +import {getActiveElementOrBody, writeDataTransferToClipboard} from '../utils' export async function copy(this: Instance) { - const doc = this.config.document - const target = getActiveElement(doc) ?? doc.activeElement ?? doc.body + const doc: Document = this.config.document + const target = getActiveElementOrBody(doc) const clipboardData = copySelection(target) diff --git a/src/clipboard/cut.ts b/src/clipboard/cut.ts index 83391f3f..f46eb2a0 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 {getActiveElement, writeDataTransferToClipboard} from '../utils' +import {getActiveElementOrBody, writeDataTransferToClipboard} from '../utils' export async function cut(this: Instance) { - const doc = this.config.document - const target = getActiveElement(doc) ?? doc.activeElement ?? doc.body + const doc: Document = this.config.document + const target = getActiveElementOrBody(doc) const clipboardData = copySelection(target) diff --git a/src/clipboard/paste.ts b/src/clipboard/paste.ts index 88fa666b..144ea15b 100644 --- a/src/clipboard/paste.ts +++ b/src/clipboard/paste.ts @@ -1,7 +1,7 @@ import type {Instance} from '../setup' import { createDataTransfer, - getActiveElement, + getActiveElementOrBody, getWindow, readDataTransferFromClipboard, } from '../utils' @@ -10,8 +10,8 @@ export async function paste( this: Instance, clipboardData?: DataTransfer | string, ) { - const doc = this.config.document - const target = getActiveElement(doc) ?? doc.activeElement ?? doc.body + const doc: Document = this.config.document + const target = getActiveElementOrBody(doc) const dataTransfer: DataTransfer = (typeof clipboardData === 'string' diff --git a/tests/_helpers/setup.ts b/tests/_helpers/setup.ts index e9ae6d9c..46aef0b4 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,19 @@ 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 = div.querySelector(FOCUSABLE_SELECTOR) + if (element) { + element.focus() + } else if (div.firstChild) { + ;(div.firstChild as HTMLElement).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 +48,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, diff --git a/tests/_helpers/shadow-input.ts b/tests/_helpers/shadow-input.ts new file mode 100644 index 00000000..332ef19e --- /dev/null +++ b/tests/_helpers/shadow-input.ts @@ -0,0 +1,52 @@ +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 ?? '' + } + + /** + * Overwrite focus handling because delegatesFocus is not implemented in jsdom + * @param options + */ + public override focus(options?: FocusOptions) { + super.focus(options) + this.$input?.focus(options) + } +} +window.customElements.define('shadow-input', ShadowInput) diff --git a/tests/clipboard/copy.ts b/tests/clipboard/copy.ts index 5306fd0a..731c8933 100644 --- a/tests/clipboard/copy.ts +++ b/tests/clipboard/copy.ts @@ -1,5 +1,6 @@ import userEvent from '#src' import {render, setup} from '#testHelpers' +import '../_helpers/shadow-input' test('copy selected value', async () => { const {getEvents, user} = setup( @@ -98,3 +99,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..5e513586 100644 --- a/tests/clipboard/cut.ts +++ b/tests/clipboard/cut.ts @@ -1,5 +1,7 @@ +import type {ShadowInput} from '../_helpers/shadow-input' import userEvent from '#src' import {render, setup} from '#testHelpers' +import '../_helpers/shadow-input' test('cut selected value', async () => { const {getEvents, user} = setup( @@ -106,3 +108,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 as ShadowInput).value.length).toEqual(0) + }) +}) diff --git a/tests/clipboard/paste.ts b/tests/clipboard/paste.ts index c792ae1a..0e9f595d 100644 --- a/tests/clipboard/paste.ts +++ b/tests/clipboard/paste.ts @@ -1,6 +1,8 @@ +import type {ShadowInput} from '../_helpers/shadow-input' import userEvent from '#src' import {render, setup} from '#testHelpers' import {createDataTransfer} from '#src/utils' +import '../_helpers/shadow-input' test('paste with empty clipboard', async () => { const {element, getEvents, user} = setup(``) @@ -149,3 +151,13 @@ 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 as ShadowInput).value).toEqual('test') + }) +}) diff --git a/tests/dom/customElement.ts b/tests/dom/customElement.ts deleted file mode 100644 index 40cdd4df..00000000 --- a/tests/dom/customElement.ts +++ /dev/null @@ -1,179 +0,0 @@ -import userEvent from '#src' -import {addListeners, setup} from '#testHelpers' -import {setUISelection} from '#src/document' - -// 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 - `) -}) - -const template = document.createElement('template') -template.innerHTML = ` - -` - -class ShadowInput extends HTMLElement { - constructor() { - super() - - this.attachShadow({mode: 'open', delegatesFocus: true}) - if (this.shadowRoot) { - this.shadowRoot.appendChild(template.content.cloneNode(true)) - } - } - - public get value(): string { - return this.shadowRoot?.querySelector('input')?.value ?? '' - } -} -window.customElements.define('shadow-input', ShadowInput) - -test('Render open shadow DOM element - type', async () => { - const {element, user} = setup('', { - focus: false, - }) - - const inputElement = element.shadowRoot?.querySelector('input') - if (inputElement) { - await user.click(inputElement) - } - await user.keyboard('test') - - expect((element as ShadowInput).value).toEqual('test') -}) - -describe('on shadow DOM', () => { - test('copy', async () => { - const {element, user} = setup('', { - focus: false, - }) - - const inputElement = element.shadowRoot?.querySelector('input') - if (inputElement) { - await user.click(inputElement) - } - await user.keyboard('test') - - if (inputElement) { - setUISelection(inputElement, {anchorOffset: 0, focusOffset: 4}) - } - - const data = await user.copy() - - expect(data?.getData('text')).toEqual('test') -}) - -test('Render open shadow DOM element - paste', async () => { - const {element, user} = setup('', { - focus: false, - }) - - const inputElement = element.shadowRoot?.querySelector('input') - if (inputElement) { - await user.click(inputElement) - } - - await user.paste('test') - - expect((element as ShadowInput).value).toEqual('test') -}) - -test('Render open shadow DOM element - cut', async () => { - const {element, user} = setup('', { - focus: false, - }) - - const inputElement = element.shadowRoot?.querySelector('input') - if (inputElement) { - await user.click(inputElement) - } - await user.keyboard('test') - - if (inputElement) { - setUISelection(inputElement, {anchorOffset: 0, focusOffset: 4}) - } - - const data = await user.cut() - - expect(data?.getData('text')).toEqual('test') - expect((element as ShadowInput).value.length).toEqual(0) -}) diff --git a/tests/utility/type.ts b/tests/utility/type.ts index 26e73131..39854d8a 100644 --- a/tests/utility/type.ts +++ b/tests/utility/type.ts @@ -1,4 +1,6 @@ +import type {ShadowInput} from '../_helpers/shadow-input' import {setup} from '#testHelpers' +import '../_helpers/shadow-input' test('type into input', async () => { const {element, getEventSnapshot, user} = setup('', { @@ -90,3 +92,18 @@ test('do nothing on disabled element', async () => { expect(getEvents()).toHaveLength(0) }) + +describe('on shadow DOM', () => { + test('type into an input element', async () => { + const {element, user} = setup('') + + const inputElement = element.shadowRoot?.querySelector('input') + if (inputElement) { + await user.click(inputElement) + } + // Skip click because delegatesFocus is not implemented in jsdom + await user.type(element, 'test', {skipClick: true}) + + expect((element as ShadowInput).value).toEqual('test') + }) +}) From 64e51e9d51a7ead4d19dcf52b3035b2890333196 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Sun, 14 Aug 2022 16:24:59 +0200 Subject: [PATCH 06/35] Added some tests for getActiveElement --- tests/utility/type.ts | 4 ---- tests/utils/focus/getActiveElement.ts | 29 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 tests/utils/focus/getActiveElement.ts diff --git a/tests/utility/type.ts b/tests/utility/type.ts index 39854d8a..849b3f77 100644 --- a/tests/utility/type.ts +++ b/tests/utility/type.ts @@ -97,10 +97,6 @@ describe('on shadow DOM', () => { test('type into an input element', async () => { const {element, user} = setup('') - const inputElement = element.shadowRoot?.querySelector('input') - if (inputElement) { - await user.click(inputElement) - } // Skip click because delegatesFocus is not implemented in jsdom await user.type(element, 'test', {skipClick: true}) diff --git a/tests/utils/focus/getActiveElement.ts b/tests/utils/focus/getActiveElement.ts new file mode 100644 index 00000000..f13f9bdc --- /dev/null +++ b/tests/utils/focus/getActiveElement.ts @@ -0,0 +1,29 @@ +import '../../_helpers/shadow-input' +import {setup} from '#testHelpers' +import {getActiveElement, getActiveElementOrBody} from '#src/utils' + +test('focus input element', async () => { + const {element} = setup('') + + expect(getActiveElement(document)).toBe(element) +}) + +test('focus should be body', async () => { + setup('', {focus: false}) + + expect(getActiveElementOrBody(document)).toBe(document.body) +}) +describe('on shadow DOM', () => { + test('focus contained input element', async () => { + const {element} = setup('') + + expect(getActiveElement(document)).toBe( + element.shadowRoot?.querySelector('input'), + ) + }) + test('focus just body', async () => { + setup('', {focus: false}) + + expect(getActiveElementOrBody(document)).toBe(document.body) + }) +}) From 87b9e0024cc985b114b8a4736a929e5389b60b16 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Sun, 14 Aug 2022 16:27:00 +0200 Subject: [PATCH 07/35] Added some tests for getActiveElement --- tests/utils/focus/getActiveElement.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/utils/focus/getActiveElement.ts b/tests/utils/focus/getActiveElement.ts index f13f9bdc..6a1b7b48 100644 --- a/tests/utils/focus/getActiveElement.ts +++ b/tests/utils/focus/getActiveElement.ts @@ -2,17 +2,20 @@ import '../../_helpers/shadow-input' import {setup} from '#testHelpers' import {getActiveElement, getActiveElementOrBody} from '#src/utils' -test('focus input element', async () => { - const {element} = setup('') +describe('focus tests with not shadow root', () => { + test('focus input element', async () => { + const {element} = setup('') - expect(getActiveElement(document)).toBe(element) -}) + expect(getActiveElement(document)).toBe(element) + }) -test('focus should be body', async () => { - setup('', {focus: false}) + test('focus should be body', async () => { + setup('', {focus: false}) - expect(getActiveElementOrBody(document)).toBe(document.body) + expect(getActiveElementOrBody(document)).toBe(document.body) + }) }) + describe('on shadow DOM', () => { test('focus contained input element', async () => { const {element} = setup('') From 1a187e0a061211890865ebade3a24ac1b28e54af Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Mon, 15 Aug 2022 18:55:56 +0200 Subject: [PATCH 08/35] Fix type import --- src/clipboard/copy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/clipboard/copy.ts b/src/clipboard/copy.ts index ca59c4ed..d44e96d4 100644 --- a/src/clipboard/copy.ts +++ b/src/clipboard/copy.ts @@ -1,5 +1,5 @@ import {copySelection} from '../document' -import {Instance} from '../setup' +import type {Instance} from '../setup' import {getActiveElementOrBody, writeDataTransferToClipboard} from '../utils' export async function copy(this: Instance) { From dbed101a5fb163b0d60e38cdb3bc1b855d8b9af7 Mon Sep 17 00:00:00 2001 From: Christian24 Date: Mon, 15 Aug 2022 19:12:30 +0200 Subject: [PATCH 09/35] Update tests/utils/focus/getActiveElement.ts Co-authored-by: Philipp Fritsche --- tests/utils/focus/getActiveElement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/focus/getActiveElement.ts b/tests/utils/focus/getActiveElement.ts index 6a1b7b48..079959ed 100644 --- a/tests/utils/focus/getActiveElement.ts +++ b/tests/utils/focus/getActiveElement.ts @@ -6,7 +6,7 @@ describe('focus tests with not shadow root', () => { test('focus input element', async () => { const {element} = setup('') - expect(getActiveElement(document)).toBe(element) + expect(getActiveElementOrBody(document)).toBe(element) }) test('focus should be body', async () => { From ddb525bef63da6eef4cb2a3c372339a8715338d9 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Mon, 15 Aug 2022 19:13:14 +0200 Subject: [PATCH 10/35] Fix type import --- tests/_helpers/shadow-input.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/_helpers/shadow-input.ts b/tests/_helpers/shadow-input.ts index 332ef19e..2f2a0b49 100644 --- a/tests/_helpers/shadow-input.ts +++ b/tests/_helpers/shadow-input.ts @@ -14,7 +14,7 @@ export class ShadowInput extends HTMLElement { constructor() { super() - this.attachShadow({mode: 'open', delegatesFocus: true}) + this.attachShadow({mode: 'open', delegatesFocus: false}) if (this.shadowRoot) { this.shadowRoot.appendChild(template.content.cloneNode(true)) this.$input = this.shadowRoot.querySelector('input') as HTMLInputElement @@ -41,7 +41,7 @@ export class ShadowInput extends HTMLElement { } /** - * Overwrite focus handling because delegatesFocus is not implemented in jsdom + * Overwrite focus handling, so that when the component is focused, the focus is delegated to the containing input. * @param options */ public override focus(options?: FocusOptions) { From 3d00cb79db2d5aecd141de097795c4d97d467476 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Mon, 15 Aug 2022 19:30:20 +0200 Subject: [PATCH 11/35] Add helper to define element. Remove describe block --- tests/_helpers/shadow-input.ts | 11 ++++++++-- tests/clipboard/copy.ts | 3 ++- tests/clipboard/cut.ts | 7 +++--- tests/clipboard/paste.ts | 7 +++--- tests/utility/type.ts | 9 ++++---- tests/utils/focus/getActiveElement.ts | 31 ++++++++++++++++----------- 6 files changed, 42 insertions(+), 26 deletions(-) diff --git a/tests/_helpers/shadow-input.ts b/tests/_helpers/shadow-input.ts index 2f2a0b49..30cdc1d2 100644 --- a/tests/_helpers/shadow-input.ts +++ b/tests/_helpers/shadow-input.ts @@ -5,7 +5,7 @@ template.innerHTML = ` ` -export class ShadowInput extends HTMLElement { +class ShadowInput extends HTMLElement { private $input?: HTMLInputElement static getObservedAttributes() { @@ -49,4 +49,11 @@ export class ShadowInput extends HTMLElement { this.$input?.focus(options) } } -window.customElements.define('shadow-input', ShadowInput) + +export type {ShadowInput} + +export function defineShadowInputCustomElementIfNotDefined() { + if (window.customElements.get('shadow-input') === undefined) { + window.customElements.define('shadow-input', ShadowInput) + } +} diff --git a/tests/clipboard/copy.ts b/tests/clipboard/copy.ts index 731c8933..74f24726 100644 --- a/tests/clipboard/copy.ts +++ b/tests/clipboard/copy.ts @@ -1,6 +1,6 @@ +import {defineShadowInputCustomElementIfNotDefined} from '../_helpers/shadow-input' import userEvent from '#src' import {render, setup} from '#testHelpers' -import '../_helpers/shadow-input' test('copy selected value', async () => { const {getEvents, user} = setup( @@ -102,6 +102,7 @@ describe('without Clipboard API', () => { describe('on shadow DOM', () => { test('copy in an input element', async () => { + defineShadowInputCustomElementIfNotDefined() const {user} = setup('', { selection: {anchorOffset: 0, focusOffset: 4}, }) diff --git a/tests/clipboard/cut.ts b/tests/clipboard/cut.ts index 5e513586..a3a669d5 100644 --- a/tests/clipboard/cut.ts +++ b/tests/clipboard/cut.ts @@ -1,7 +1,7 @@ import type {ShadowInput} from '../_helpers/shadow-input' +import {defineShadowInputCustomElementIfNotDefined} from '../_helpers/shadow-input' import userEvent from '#src' import {render, setup} from '#testHelpers' -import '../_helpers/shadow-input' test('cut selected value', async () => { const {getEvents, user} = setup( @@ -110,7 +110,8 @@ describe('without Clipboard API', () => { }) describe('on shadow DOM', () => { test('cut in an input element', async () => { - const {element, user} = setup( + defineShadowInputCustomElementIfNotDefined() + const {element, user} = setup( '', { selection: {anchorOffset: 0, focusOffset: 4}, @@ -120,6 +121,6 @@ describe('on shadow DOM', () => { const data = await user.cut() expect(data?.getData('text')).toEqual('test') - expect((element as ShadowInput).value.length).toEqual(0) + expect(element.value.length).toEqual(0) }) }) diff --git a/tests/clipboard/paste.ts b/tests/clipboard/paste.ts index 0e9f595d..e5fd0370 100644 --- a/tests/clipboard/paste.ts +++ b/tests/clipboard/paste.ts @@ -1,8 +1,8 @@ import type {ShadowInput} from '../_helpers/shadow-input' +import {defineShadowInputCustomElementIfNotDefined} from '../_helpers/shadow-input' import userEvent from '#src' import {render, setup} from '#testHelpers' import {createDataTransfer} from '#src/utils' -import '../_helpers/shadow-input' test('paste with empty clipboard', async () => { const {element, getEvents, user} = setup(``) @@ -154,10 +154,11 @@ describe('without Clipboard API', () => { describe('on shadow DOM', () => { test('paste into an input element', async () => { - const {element, user} = setup('') + defineShadowInputCustomElementIfNotDefined() + const {element, user} = setup('') await user.paste('test') - expect((element as ShadowInput).value).toEqual('test') + expect(element.value).toEqual('test') }) }) diff --git a/tests/utility/type.ts b/tests/utility/type.ts index 849b3f77..b8abe23b 100644 --- a/tests/utility/type.ts +++ b/tests/utility/type.ts @@ -1,6 +1,6 @@ import type {ShadowInput} from '../_helpers/shadow-input' +import {defineShadowInputCustomElementIfNotDefined} from '../_helpers/shadow-input' import {setup} from '#testHelpers' -import '../_helpers/shadow-input' test('type into input', async () => { const {element, getEventSnapshot, user} = setup('', { @@ -95,11 +95,12 @@ test('do nothing on disabled element', async () => { describe('on shadow DOM', () => { test('type into an input element', async () => { - const {element, user} = setup('') + defineShadowInputCustomElementIfNotDefined() + const {element, user} = setup('') - // Skip click because delegatesFocus is not implemented in jsdom + // Skip click because the element is already focused await user.type(element, 'test', {skipClick: true}) - expect((element as ShadowInput).value).toEqual('test') + expect(element.value).toEqual('test') }) }) diff --git a/tests/utils/focus/getActiveElement.ts b/tests/utils/focus/getActiveElement.ts index 079959ed..92e0435d 100644 --- a/tests/utils/focus/getActiveElement.ts +++ b/tests/utils/focus/getActiveElement.ts @@ -1,23 +1,25 @@ -import '../../_helpers/shadow-input' +import { + defineShadowInputCustomElementIfNotDefined, + ShadowInput, +} from '../../_helpers/shadow-input' import {setup} from '#testHelpers' import {getActiveElement, getActiveElementOrBody} from '#src/utils' -describe('focus tests with not shadow root', () => { - test('focus input element', async () => { - const {element} = setup('') +test('focused input element', async () => { + const {element} = setup('') - expect(getActiveElementOrBody(document)).toBe(element) - }) - - test('focus should be body', async () => { - setup('', {focus: false}) + expect(getActiveElementOrBody(document)).toBe(element) +}) - expect(getActiveElementOrBody(document)).toBe(document.body) - }) +test('focus should be body', async () => { + const {element} = setup('', {focus: false}) + element.blur() + expect(getActiveElementOrBody(document)).toBe(document.body) }) describe('on shadow DOM', () => { test('focus contained input element', async () => { + defineShadowInputCustomElementIfNotDefined() const {element} = setup('') expect(getActiveElement(document)).toBe( @@ -25,8 +27,11 @@ describe('on shadow DOM', () => { ) }) test('focus just body', async () => { - setup('', {focus: false}) - + defineShadowInputCustomElementIfNotDefined() + const {element} = setup('', { + focus: false, + }) + element.blur() expect(getActiveElementOrBody(document)).toBe(document.body) }) }) From a6516414d92e87dc35ffdba6cca4ff4d53788991 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Mon, 15 Aug 2022 19:41:05 +0200 Subject: [PATCH 12/35] Use getActiveElementOrBody --- src/event/focus.ts | 6 +++--- tests/utils/focus/getActiveElement.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) 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/tests/utils/focus/getActiveElement.ts b/tests/utils/focus/getActiveElement.ts index 92e0435d..4037cae3 100644 --- a/tests/utils/focus/getActiveElement.ts +++ b/tests/utils/focus/getActiveElement.ts @@ -3,7 +3,7 @@ import { ShadowInput, } from '../../_helpers/shadow-input' import {setup} from '#testHelpers' -import {getActiveElement, getActiveElementOrBody} from '#src/utils' +import {getActiveElementOrBody} from '#src/utils' test('focused input element', async () => { const {element} = setup('') @@ -22,7 +22,7 @@ describe('on shadow DOM', () => { defineShadowInputCustomElementIfNotDefined() const {element} = setup('') - expect(getActiveElement(document)).toBe( + expect(getActiveElementOrBody(document)).toBe( element.shadowRoot?.querySelector('input'), ) }) From 7f22d6ae68ea5c17e3967c8015e029c8f4f53750 Mon Sep 17 00:00:00 2001 From: Christian24 Date: Mon, 15 Aug 2022 19:45:50 +0200 Subject: [PATCH 13/35] Update tests/utils/focus/getActiveElement.ts Co-authored-by: Philipp Fritsche --- tests/utils/focus/getActiveElement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/focus/getActiveElement.ts b/tests/utils/focus/getActiveElement.ts index 4037cae3..50b09e4a 100644 --- a/tests/utils/focus/getActiveElement.ts +++ b/tests/utils/focus/getActiveElement.ts @@ -18,7 +18,7 @@ test('focus should be body', async () => { }) describe('on shadow DOM', () => { - test('focus contained input element', async () => { + test('get focused element inside shadow tree', async () => { defineShadowInputCustomElementIfNotDefined() const {element} = setup('') From 0974cd007a0b15c609b986eb036b2d40a0ccdc4f Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Mon, 15 Aug 2022 19:53:22 +0200 Subject: [PATCH 14/35] Make focusing custom elements safer --- tests/_helpers/setup.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/_helpers/setup.ts b/tests/_helpers/setup.ts index 46aef0b4..9f249c69 100644 --- a/tests/_helpers/setup.ts +++ b/tests/_helpers/setup.ts @@ -31,8 +31,9 @@ export function render( const element: HTMLElement | null = div.querySelector(FOCUSABLE_SELECTOR) if (element) { element.focus() - } else if (div.firstChild) { - ;(div.firstChild as HTMLElement).focus() + } else if (div.firstElementChild?.tagName.includes('-')) { + // If the element's tag contains - it is a custom element, so it is focusable. + ;(div.firstElementChild as HTMLElement).focus() } } From a6cebffc1a4e3081197e827e74763b30a5d7355f Mon Sep 17 00:00:00 2001 From: Christian24 Date: Tue, 16 Aug 2022 19:02:29 +0200 Subject: [PATCH 15/35] Update tests/utils/focus/getActiveElement.ts Co-authored-by: Philipp Fritsche --- tests/utils/focus/getActiveElement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/focus/getActiveElement.ts b/tests/utils/focus/getActiveElement.ts index 50b09e4a..2a1b792d 100644 --- a/tests/utils/focus/getActiveElement.ts +++ b/tests/utils/focus/getActiveElement.ts @@ -11,7 +11,7 @@ test('focused input element', async () => { expect(getActiveElementOrBody(document)).toBe(element) }) -test('focus should be body', async () => { +test('default to body as active element', async () => { const {element} = setup('', {focus: false}) element.blur() expect(getActiveElementOrBody(document)).toBe(document.body) From 11b44c566a89eb51c9d0bb00d328691a58ed876f Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Tue, 16 Aug 2022 19:37:28 +0200 Subject: [PATCH 16/35] Remove test --- tests/utils/focus/getActiveElement.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/tests/utils/focus/getActiveElement.ts b/tests/utils/focus/getActiveElement.ts index 2a1b792d..c303f476 100644 --- a/tests/utils/focus/getActiveElement.ts +++ b/tests/utils/focus/getActiveElement.ts @@ -1,7 +1,4 @@ -import { - defineShadowInputCustomElementIfNotDefined, - ShadowInput, -} from '../../_helpers/shadow-input' +import {defineShadowInputCustomElementIfNotDefined} from '../../_helpers/shadow-input' import {setup} from '#testHelpers' import {getActiveElementOrBody} from '#src/utils' @@ -26,12 +23,4 @@ describe('on shadow DOM', () => { element.shadowRoot?.querySelector('input'), ) }) - test('focus just body', async () => { - defineShadowInputCustomElementIfNotDefined() - const {element} = setup('', { - focus: false, - }) - element.blur() - expect(getActiveElementOrBody(document)).toBe(document.body) - }) }) From 188b250e1abe08d394e6a333918a8596c68c889b Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Tue, 16 Aug 2022 19:40:46 +0200 Subject: [PATCH 17/35] Removed types --- src/clipboard/copy.ts | 2 +- src/clipboard/cut.ts | 2 +- src/clipboard/paste.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/clipboard/copy.ts b/src/clipboard/copy.ts index d44e96d4..4c5b3054 100644 --- a/src/clipboard/copy.ts +++ b/src/clipboard/copy.ts @@ -3,7 +3,7 @@ import type {Instance} from '../setup' import {getActiveElementOrBody, writeDataTransferToClipboard} from '../utils' export async function copy(this: Instance) { - const doc: Document = this.config.document + const doc = this.config.document const target = getActiveElementOrBody(doc) const clipboardData = copySelection(target) diff --git a/src/clipboard/cut.ts b/src/clipboard/cut.ts index f46eb2a0..ec923004 100644 --- a/src/clipboard/cut.ts +++ b/src/clipboard/cut.ts @@ -3,7 +3,7 @@ import type {Instance} from '../setup' import {getActiveElementOrBody, writeDataTransferToClipboard} from '../utils' export async function cut(this: Instance) { - const doc: Document = this.config.document + const doc = this.config.document const target = getActiveElementOrBody(doc) const clipboardData = copySelection(target) diff --git a/src/clipboard/paste.ts b/src/clipboard/paste.ts index 144ea15b..aa21079c 100644 --- a/src/clipboard/paste.ts +++ b/src/clipboard/paste.ts @@ -10,7 +10,7 @@ export async function paste( this: Instance, clipboardData?: DataTransfer | string, ) { - const doc: Document = this.config.document + const doc = this.config.document const target = getActiveElementOrBody(doc) const dataTransfer: DataTransfer = From 5f6d6d085e1e5387ebccf912dbe084c27b5731c4 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Tue, 16 Aug 2022 21:12:52 +0200 Subject: [PATCH 18/35] Add focus helper --- tests/_helpers/setup.ts | 38 +++++++++++++++++++++++++++------- tests/_helpers/shadow-input.ts | 11 +--------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/tests/_helpers/setup.ts b/tests/_helpers/setup.ts index 9f249c69..c29f83f2 100644 --- a/tests/_helpers/setup.ts +++ b/tests/_helpers/setup.ts @@ -28,13 +28,8 @@ export function render( if (typeof focus === 'string') { ;(assertSingleNodeFromXPath(focus, div) as HTMLElement).focus() } else if (focus !== false) { - const element: HTMLElement | null = div.querySelector(FOCUSABLE_SELECTOR) - if (element) { - element.focus() - } else if (div.firstElementChild?.tagName.includes('-')) { - // If the element's tag contains - it is a custom element, so it is focusable. - ;(div.firstElementChild as HTMLElement).focus() - } + const element: HTMLElement | null = getFocusableElement(div) + element?.focus() } if (selection) { @@ -108,3 +103,32 @@ export function setup( ...render(ui, {eventHandlers, focus, selection}), } } +export function getFocusableElement( + parent: Element | ShadowRoot, +): HTMLElement | null { + const possibleFocusableElement: HTMLElement | null = + parent.querySelector(FOCUSABLE_SELECTOR) + if (possibleFocusableElement) { + return possibleFocusableElement + } + + const children = Array.from(parent.children) + for (const child of children) { + if ('shadowRoot' in child && child.shadowRoot) { + //JSDOM currently does not support delegatesFocus, find the focusable element ourselves, if delegatesFocus is undefined + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (child.shadowRoot.delegatesFocus === undefined) { + const possibleFocusableChildElement = getFocusableElement( + child.shadowRoot, + ) + if (possibleFocusableChildElement) { + return possibleFocusableChildElement + } + } else { + return child as HTMLElement + } + } + } + return null +} diff --git a/tests/_helpers/shadow-input.ts b/tests/_helpers/shadow-input.ts index 30cdc1d2..404103ba 100644 --- a/tests/_helpers/shadow-input.ts +++ b/tests/_helpers/shadow-input.ts @@ -14,7 +14,7 @@ class ShadowInput extends HTMLElement { constructor() { super() - this.attachShadow({mode: 'open', delegatesFocus: false}) + this.attachShadow({mode: 'open', delegatesFocus: true}) if (this.shadowRoot) { this.shadowRoot.appendChild(template.content.cloneNode(true)) this.$input = this.shadowRoot.querySelector('input') as HTMLInputElement @@ -39,15 +39,6 @@ class ShadowInput extends HTMLElement { public get value(): string { return this.$input?.value ?? '' } - - /** - * Overwrite focus handling, so that when the component is focused, the focus is delegated to the containing input. - * @param options - */ - public override focus(options?: FocusOptions) { - super.focus(options) - this.$input?.focus(options) - } } export type {ShadowInput} From a9810573dc71a5553c57e466661a36398529b1b8 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Tue, 16 Aug 2022 21:15:12 +0200 Subject: [PATCH 19/35] Delete test --- tests/utility/type.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/utility/type.ts b/tests/utility/type.ts index b8abe23b..26e73131 100644 --- a/tests/utility/type.ts +++ b/tests/utility/type.ts @@ -1,5 +1,3 @@ -import type {ShadowInput} from '../_helpers/shadow-input' -import {defineShadowInputCustomElementIfNotDefined} from '../_helpers/shadow-input' import {setup} from '#testHelpers' test('type into input', async () => { @@ -92,15 +90,3 @@ test('do nothing on disabled element', async () => { expect(getEvents()).toHaveLength(0) }) - -describe('on shadow DOM', () => { - test('type into an input element', async () => { - defineShadowInputCustomElementIfNotDefined() - const {element, user} = setup('') - - // Skip click because the element is already focused - await user.type(element, 'test', {skipClick: true}) - - expect(element.value).toEqual('test') - }) -}) From 63d558631381d5ad95cc676ba084a0d499e57760 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Tue, 16 Aug 2022 21:41:24 +0200 Subject: [PATCH 20/35] Improve focus handling for setup --- tests/_helpers/setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/_helpers/setup.ts b/tests/_helpers/setup.ts index c29f83f2..2dbd7331 100644 --- a/tests/_helpers/setup.ts +++ b/tests/_helpers/setup.ts @@ -125,7 +125,7 @@ export function getFocusableElement( if (possibleFocusableChildElement) { return possibleFocusableChildElement } - } else { + } else if (child.shadowRoot.delegatesFocus) { return child as HTMLElement } } From 67858c157e9d7b5d5360f8e2548a05c5b8d9ff19 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Tue, 16 Aug 2022 22:21:28 +0200 Subject: [PATCH 21/35] Improve focus handling for setup Add test for keyboard input on open shadow dom --- tests/_helpers/setup.ts | 17 +++++------------ tests/keyboard/index.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/_helpers/setup.ts b/tests/_helpers/setup.ts index 2dbd7331..4e177992 100644 --- a/tests/_helpers/setup.ts +++ b/tests/_helpers/setup.ts @@ -115,18 +115,11 @@ export function getFocusableElement( const children = Array.from(parent.children) for (const child of children) { if ('shadowRoot' in child && child.shadowRoot) { - //JSDOM currently does not support delegatesFocus, find the focusable element ourselves, if delegatesFocus is undefined - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (child.shadowRoot.delegatesFocus === undefined) { - const possibleFocusableChildElement = getFocusableElement( - child.shadowRoot, - ) - if (possibleFocusableChildElement) { - return possibleFocusableChildElement - } - } else if (child.shadowRoot.delegatesFocus) { - return child as HTMLElement + const possibleFocusableChildElement = getFocusableElement( + child.shadowRoot, + ) + if (possibleFocusableChildElement) { + return possibleFocusableChildElement } } } diff --git a/tests/keyboard/index.ts b/tests/keyboard/index.ts index af482eaf..26196780 100644 --- a/tests/keyboard/index.ts +++ b/tests/keyboard/index.ts @@ -1,5 +1,9 @@ import userEvent from '#src' import {addListeners, render, setup} from '#testHelpers' +import { + defineShadowInputCustomElementIfNotDefined, + ShadowInput, +} from '../_helpers/shadow-input' it('type without focus', async () => { const {element, user} = setup('', {focus: false}) @@ -163,3 +167,14 @@ test('disabling activeElement moves action to HTMLBodyElement', async () => { body - keyup: c `) }) + +describe('on shadow DOM', () => { + test('type into an input element', async () => { + defineShadowInputCustomElementIfNotDefined() + const {element, user} = setup('') + + await user.keyboard('test') + + expect(element.value).toEqual('test') + }) +}) From e05e521a7a8355f7af5f8288a9307c801fb7aa31 Mon Sep 17 00:00:00 2001 From: Christian Siebmanns Date: Tue, 16 Aug 2022 23:03:43 +0200 Subject: [PATCH 22/35] Improve focus handling for setup --- tests/_helpers/setup.ts | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/tests/_helpers/setup.ts b/tests/_helpers/setup.ts index 4e177992..a87be5b0 100644 --- a/tests/_helpers/setup.ts +++ b/tests/_helpers/setup.ts @@ -28,7 +28,7 @@ export function render( if (typeof focus === 'string') { ;(assertSingleNodeFromXPath(focus, div) as HTMLElement).focus() } else if (focus !== false) { - const element: HTMLElement | null = getFocusableElement(div) + const element: HTMLElement | null = findFocusable(div) element?.focus() } @@ -103,23 +103,14 @@ export function setup( ...render(ui, {eventHandlers, focus, selection}), } } -export function getFocusableElement( - parent: Element | ShadowRoot, -): HTMLElement | null { - const possibleFocusableElement: HTMLElement | null = - parent.querySelector(FOCUSABLE_SELECTOR) - if (possibleFocusableElement) { - return possibleFocusableElement - } - - const children = Array.from(parent.children) - for (const child of children) { - if ('shadowRoot' in child && child.shadowRoot) { - const possibleFocusableChildElement = getFocusableElement( - child.shadowRoot, - ) - if (possibleFocusableChildElement) { - return possibleFocusableChildElement +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 } } } From 0e07093dc5f4ad6eda05703da3fc087c46cff5b4 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 18 Aug 2022 09:59:27 +0000 Subject: [PATCH 23/35] ease setting up tests --- tests/_helpers/elements/index.ts | 21 +++++++++++++++++++ tests/_helpers/{ => elements}/shadow-input.ts | 10 +-------- tests/_helpers/index.ts | 6 ++++++ tests/clipboard/copy.ts | 2 -- tests/clipboard/cut.ts | 4 +--- tests/clipboard/paste.ts | 9 ++++---- tests/keyboard/index.ts | 11 ++++------ tests/utils/focus/getActiveElement.ts | 2 -- 8 files changed, 37 insertions(+), 28 deletions(-) create mode 100644 tests/_helpers/elements/index.ts rename tests/_helpers/{ => elements}/shadow-input.ts (78%) 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/shadow-input.ts b/tests/_helpers/elements/shadow-input.ts similarity index 78% rename from tests/_helpers/shadow-input.ts rename to tests/_helpers/elements/shadow-input.ts index 404103ba..1e454c6e 100644 --- a/tests/_helpers/shadow-input.ts +++ b/tests/_helpers/elements/shadow-input.ts @@ -5,7 +5,7 @@ template.innerHTML = ` ` -class ShadowInput extends HTMLElement { +export class ShadowInput extends HTMLElement { private $input?: HTMLInputElement static getObservedAttributes() { @@ -40,11 +40,3 @@ class ShadowInput extends HTMLElement { return this.$input?.value ?? '' } } - -export type {ShadowInput} - -export function defineShadowInputCustomElementIfNotDefined() { - if (window.customElements.get('shadow-input') === undefined) { - window.customElements.define('shadow-input', ShadowInput) - } -} diff --git a/tests/_helpers/index.ts b/tests/_helpers/index.ts index a4c933c1..13d293be 100644 --- a/tests/_helpers/index.ts +++ b/tests/_helpers/index.ts @@ -1,3 +1,5 @@ +import {CustomElements, registerCustomElements} from './elements' + // this is pretty helpful: // https://codesandbox.io/s/quizzical-worker-eo909 @@ -11,5 +13,9 @@ expect.addSnapshotSerializer({ print: val => String((val)?.snapshot), }) +registerCustomElements() + +export type {CustomElements} + export {render, setup} from './setup' export {addEventListener, addListeners} from './listeners' diff --git a/tests/clipboard/copy.ts b/tests/clipboard/copy.ts index 74f24726..7d3822c1 100644 --- a/tests/clipboard/copy.ts +++ b/tests/clipboard/copy.ts @@ -1,4 +1,3 @@ -import {defineShadowInputCustomElementIfNotDefined} from '../_helpers/shadow-input' import userEvent from '#src' import {render, setup} from '#testHelpers' @@ -102,7 +101,6 @@ describe('without Clipboard API', () => { describe('on shadow DOM', () => { test('copy in an input element', async () => { - defineShadowInputCustomElementIfNotDefined() const {user} = setup('', { selection: {anchorOffset: 0, focusOffset: 4}, }) diff --git a/tests/clipboard/cut.ts b/tests/clipboard/cut.ts index a3a669d5..422747e3 100644 --- a/tests/clipboard/cut.ts +++ b/tests/clipboard/cut.ts @@ -1,5 +1,4 @@ -import type {ShadowInput} from '../_helpers/shadow-input' -import {defineShadowInputCustomElementIfNotDefined} from '../_helpers/shadow-input' +import type {ShadowInput} from '../_helpers/elements/shadow-input' import userEvent from '#src' import {render, setup} from '#testHelpers' @@ -110,7 +109,6 @@ describe('without Clipboard API', () => { }) describe('on shadow DOM', () => { test('cut in an input element', async () => { - defineShadowInputCustomElementIfNotDefined() const {element, user} = setup( '', { diff --git a/tests/clipboard/paste.ts b/tests/clipboard/paste.ts index e5fd0370..712b1131 100644 --- a/tests/clipboard/paste.ts +++ b/tests/clipboard/paste.ts @@ -1,7 +1,5 @@ -import type {ShadowInput} from '../_helpers/shadow-input' -import {defineShadowInputCustomElementIfNotDefined} from '../_helpers/shadow-input' 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 () => { @@ -154,8 +152,9 @@ describe('without Clipboard API', () => { describe('on shadow DOM', () => { test('paste into an input element', async () => { - defineShadowInputCustomElementIfNotDefined() - const {element, user} = setup('') + const {element, user} = setup( + '', + ) await user.paste('test') diff --git a/tests/keyboard/index.ts b/tests/keyboard/index.ts index 26196780..e5bb9502 100644 --- a/tests/keyboard/index.ts +++ b/tests/keyboard/index.ts @@ -1,9 +1,5 @@ import userEvent from '#src' -import {addListeners, render, setup} from '#testHelpers' -import { - defineShadowInputCustomElementIfNotDefined, - ShadowInput, -} from '../_helpers/shadow-input' +import {addListeners, CustomElements, render, setup} from '#testHelpers' it('type without focus', async () => { const {element, user} = setup('', {focus: false}) @@ -170,8 +166,9 @@ test('disabling activeElement moves action to HTMLBodyElement', async () => { describe('on shadow DOM', () => { test('type into an input element', async () => { - defineShadowInputCustomElementIfNotDefined() - const {element, user} = setup('') + const {element, user} = setup( + '', + ) await user.keyboard('test') diff --git a/tests/utils/focus/getActiveElement.ts b/tests/utils/focus/getActiveElement.ts index c303f476..13d630ad 100644 --- a/tests/utils/focus/getActiveElement.ts +++ b/tests/utils/focus/getActiveElement.ts @@ -1,4 +1,3 @@ -import {defineShadowInputCustomElementIfNotDefined} from '../../_helpers/shadow-input' import {setup} from '#testHelpers' import {getActiveElementOrBody} from '#src/utils' @@ -16,7 +15,6 @@ test('default to body as active element', async () => { describe('on shadow DOM', () => { test('get focused element inside shadow tree', async () => { - defineShadowInputCustomElementIfNotDefined() const {element} = setup('') expect(getActiveElementOrBody(document)).toBe( From 7a1e5c9b996af1e6ed8e392a416675ce9bf61395 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 18 Aug 2022 10:02:31 +0000 Subject: [PATCH 24/35] clean up import --- tests/clipboard/cut.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/clipboard/cut.ts b/tests/clipboard/cut.ts index 422747e3..f1d47bed 100644 --- a/tests/clipboard/cut.ts +++ b/tests/clipboard/cut.ts @@ -1,6 +1,5 @@ -import type {ShadowInput} from '../_helpers/elements/shadow-input' import userEvent from '#src' -import {render, setup} from '#testHelpers' +import {CustomElements, render, setup} from '#testHelpers' test('cut selected value', async () => { const {getEvents, user} = setup( @@ -109,7 +108,7 @@ describe('without Clipboard API', () => { }) describe('on shadow DOM', () => { test('cut in an input element', async () => { - const {element, user} = setup( + const {element, user} = setup( '', { selection: {anchorOffset: 0, focusOffset: 4}, From cbe1391f14f8319ec737e48b3878caad80c6f3a5 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 18 Aug 2022 10:14:34 +0000 Subject: [PATCH 25/35] prevent accidental import of wrong util --- src/utils/focus/getActiveElement.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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) { From 6ecc7ccd51bfd3e28df5185795a3355427d04038 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 18 Aug 2022 10:38:19 +0000 Subject: [PATCH 26/35] add query helper --- tests/_helpers/setup.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/_helpers/setup.ts b/tests/_helpers/setup.ts index a87be5b0..cd7650fc 100644 --- a/tests/_helpers/setup.ts +++ b/tests/_helpers/setup.ts @@ -1,3 +1,4 @@ +import {prettyDOM} from '@testing-library/dom' import {addListeners, EventHandlers} from './listeners' import userEvent from '#src' import {Options} from '#src/options' @@ -71,6 +72,8 @@ export function render( ), xpathNode: (xpath: string) => assertSingleNodeFromXPath(xpath, div) as NodeType, + query: ((...selectors: string[]) => + assertDeepQuery(div, ...selectors)) as deepQuery, } } @@ -89,6 +92,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, { From 3950800ef21c621ee1fb4dca17b33fb3f3c631b3 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Thu, 18 Aug 2022 18:58:34 +0000 Subject: [PATCH 27/35] test: move focus per mousedown in shadow DOM --- tests/_helpers/elements/hello-world.ts | 9 +++++ tests/_helpers/elements/index.ts | 2 + tests/_helpers/index.ts | 14 +++++++ tests/_helpers/toBeActive.ts | 35 +++++++++++++++++ tests/pointer/select.ts | 53 ++++++++++++++++++++++++++ 5 files changed, 113 insertions(+) create mode 100644 tests/_helpers/elements/hello-world.ts create mode 100644 tests/_helpers/toBeActive.ts 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 index 20015454..eadfcd51 100644 --- a/tests/_helpers/elements/index.ts +++ b/tests/_helpers/elements/index.ts @@ -1,6 +1,8 @@ +import {HelloWorld} from './hello-world' import {ShadowInput} from './shadow-input' const customElements = { + 'hello-world': HelloWorld, 'shadow-input': ShadowInput, } diff --git a/tests/_helpers/index.ts b/tests/_helpers/index.ts index 13d293be..4e0ef39a 100644 --- a/tests/_helpers/index.ts +++ b/tests/_helpers/index.ts @@ -1,4 +1,5 @@ import {CustomElements, registerCustomElements} from './elements' +import {toBeActive} from './toBeActive' // this is pretty helpful: // https://codesandbox.io/s/quizzical-worker-eo909 @@ -13,6 +14,19 @@ 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} 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/pointer/select.ts b/tests/pointer/select.ts index 3b54070d..6c5be013 100644 --- a/tests/pointer/select.ts +++ b/tests/pointer/select.ts @@ -16,6 +16,59 @@ 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('into 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('into element which delegatesFocus to nothing', async () => { + const {element, user} = setup( + ``, + ) + + await user.pointer({keys: '[MouseLeft>]', target: element}) + + // This is not consistent across browsers if there is a focusable descendant. + // Firefox falls back to the closest focusable descendant + // of the shadow host as if `delegatesFocus` was `false`. + // Chrome falls back to `document.body`. + expect(document.body).toBeActive() + }) + + test('into 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(`
From 50a43ffa03c3b3d4c6c082a33c9da7f0a73a931c Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Fri, 19 Aug 2022 10:32:45 +0000 Subject: [PATCH 28/35] handle shadow hosts when moving focus --- src/event/behavior/click.ts | 2 +- src/event/behavior/keydown.ts | 11 +--- src/event/focus.ts | 58 ++++++++++++++--- src/event/selection/updateSelectionOnFocus.ts | 26 ++++---- src/utils/focus/isFocusable.ts | 63 ++++++++++++++++++- tests/_helpers/elements/index.ts | 2 + tests/_helpers/elements/shadow-host.ts | 10 +++ tests/pointer/select.ts | 35 ++++++++--- 8 files changed, 167 insertions(+), 40 deletions(-) create mode 100644 tests/_helpers/elements/shadow-host.ts 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 e650978d..b140edc0 100644 --- a/src/event/focus.ts +++ b/src/event/focus.ts @@ -1,23 +1,65 @@ -import {findClosest, getActiveElementOrBody, 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 = 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 descendant. + // Firefox falls back to the closest focusable descendant + // 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 && hasOwnSelection(target)) { + setUISelection(target, { + anchorOffset: 0, + focusOffset: target.value.length, + }) + } + + updateSelectionOnFocus(target) + } } export function blurElement(element: Element) { @@ -26,5 +68,5 @@ export function blurElement(element: 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/isFocusable.ts b/src/utils/focus/isFocusable.ts index ba1ce040..d2973fbd 100644 --- a/src/utils/focus/isFocusable.ts +++ b/src/utils/focus/isFocusable.ts @@ -1,5 +1,66 @@ import {FOCUSABLE_SELECTOR} from './selector' +/** + * 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 that don't delegateFocus 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.matches(FOCUSABLE_SELECTOR) + return !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/tests/_helpers/elements/index.ts b/tests/_helpers/elements/index.ts index eadfcd51..aea20178 100644 --- a/tests/_helpers/elements/index.ts +++ b/tests/_helpers/elements/index.ts @@ -1,9 +1,11 @@ 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 = { diff --git a/tests/_helpers/elements/shadow-host.ts b/tests/_helpers/elements/shadow-host.ts new file mode 100644 index 00000000..f8240b68 --- /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 = `${this.getAttribute('innerHTML')}` + } +} diff --git a/tests/pointer/select.ts b/tests/pointer/select.ts index 6c5be013..83beccd9 100644 --- a/tests/pointer/select.ts +++ b/tests/pointer/select.ts @@ -30,7 +30,7 @@ describe('move focus per mousedown in shadow DOM', () => { expect(input).toHaveProperty('selectionStart', 3) }) - test('into element which delegatesFocus to input', async () => { + test('on element which delegatesFocus to input', async () => { const {element, user, query} = setup( ``, {focus: '//button'}, @@ -44,21 +44,42 @@ describe('move focus per mousedown in shadow DOM', () => { expect(input).toHaveProperty('selectionEnd', 3) }) - test('into element which delegatesFocus to nothing', async () => { + 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}) - // This is not consistent across browsers if there is a focusable descendant. - // Firefox falls back to the closest focusable descendant - // of the shadow host as if `delegatesFocus` was `false`. - // Chrome falls back to `document.body`. expect(document.body).toBeActive() }) - test('into element without delegatesFocus', async () => { + 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( ``, ) From 3d036b9c09a71b77c98321081b63ad39b2ab11bc Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Fri, 19 Aug 2022 10:38:10 +0000 Subject: [PATCH 29/35] use util in test setup --- tests/_helpers/setup.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/tests/_helpers/setup.ts b/tests/_helpers/setup.ts index cd7650fc..f5ae4f06 100644 --- a/tests/_helpers/setup.ts +++ b/tests/_helpers/setup.ts @@ -2,7 +2,7 @@ import {prettyDOM} from '@testing-library/dom' import {addListeners, EventHandlers} from './listeners' import userEvent from '#src' import {Options} from '#src/options' -import {FOCUSABLE_SELECTOR, getActiveElementOrBody} from '#src/utils' +import {findFocusable, getActiveElementOrBody} from '#src/utils' import {setSelection} from '#src/event/selection' export function render( @@ -29,8 +29,7 @@ export function render( if (typeof focus === 'string') { ;(assertSingleNodeFromXPath(focus, div) as HTMLElement).focus() } else if (focus !== false) { - const element: HTMLElement | null = findFocusable(div) - element?.focus() + findFocusable(div)?.focus() } if (selection) { @@ -140,16 +139,3 @@ 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 -} From 63d0855403b339af17f2cc29b1563e33117652ae Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Fri, 19 Aug 2022 10:40:22 +0000 Subject: [PATCH 30/35] clean up --- src/event/focus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/event/focus.ts b/src/event/focus.ts index b140edc0..52488494 100644 --- a/src/event/focus.ts +++ b/src/event/focus.ts @@ -51,7 +51,7 @@ function doFocus(target: HTMLElement, select: boolean, source: Element) { wrapEvent(() => target.focus(), source) if (hasOwnSelection(target)) { - if (select && hasOwnSelection(target)) { + if (select) { setUISelection(target, { anchorOffset: 0, focusOffset: target.value.length, From 57585a2fb196bcd77df55e49add19ddd2f4a71db Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Fri, 19 Aug 2022 10:44:25 +0000 Subject: [PATCH 31/35] clean up --- tests/_helpers/elements/shadow-host.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/_helpers/elements/shadow-host.ts b/tests/_helpers/elements/shadow-host.ts index f8240b68..74b31c2b 100644 --- a/tests/_helpers/elements/shadow-host.ts +++ b/tests/_helpers/elements/shadow-host.ts @@ -5,6 +5,6 @@ export class ShadowHost extends HTMLElement { this.attachShadow({ mode: 'open', delegatesFocus: true, - }).innerHTML = `${this.getAttribute('innerHTML')}` + }).innerHTML = String(this.getAttribute('innerHTML')) } } From 640700d5d9bb3ae6202dedbc958b0c5a41c145d1 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Fri, 19 Aug 2022 10:57:56 +0000 Subject: [PATCH 32/35] refactor and guard against invalid `tabindex` --- .../focus/{isFocusable.ts => focusable.ts} | 24 +++++++++++++++++-- src/utils/focus/getTabDestination.ts | 2 +- src/utils/focus/selector.ts | 10 -------- src/utils/index.ts | 3 +-- 4 files changed, 24 insertions(+), 15 deletions(-) rename src/utils/focus/{isFocusable.ts => focusable.ts} (74%) delete mode 100644 src/utils/focus/selector.ts diff --git a/src/utils/focus/isFocusable.ts b/src/utils/focus/focusable.ts similarity index 74% rename from src/utils/focus/isFocusable.ts rename to src/utils/focus/focusable.ts index d2973fbd..65c8a56a 100644 --- a/src/utils/focus/isFocusable.ts +++ b/src/utils/focus/focusable.ts @@ -1,4 +1,20 @@ -import {FOCUSABLE_SELECTOR} from './selector' +/** + * 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()`. @@ -16,7 +32,11 @@ export function isFocusTarget(element: Element): element is HTMLElement { } export function isFocusable(element: Element): element is HTMLElement { - return !delegatesFocus(element) && element.matches(FOCUSABLE_SELECTOR) + return ( + !element.tagName.includes('-') && + !delegatesFocus(element) && + element.matches(FOCUSABLE_SELECTOR) + ) } export function delegatesFocus( 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/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' From 1ff9665a2c8f44ac4be1f75cf5bdb90954e43cb4 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Fri, 19 Aug 2022 11:11:20 +0000 Subject: [PATCH 33/35] fix comment --- src/event/focus.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/event/focus.ts b/src/event/focus.ts index 52488494..bba346a7 100644 --- a/src/event/focus.ts +++ b/src/event/focus.ts @@ -28,8 +28,8 @@ export function focusElement(element: Element, select: boolean = false) { if (effectiveTarget) { doFocus(effectiveTarget, true, element) } else { - // This is not consistent across browsers if there is a focusable descendant. - // Firefox falls back to the closest focusable descendant + // 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. From 22c02347953b7f1561b1c727f88f022fe33438cc Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Fri, 19 Aug 2022 11:15:09 +0000 Subject: [PATCH 34/35] comment typo --- src/utils/focus/focusable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/focus/focusable.ts b/src/utils/focus/focusable.ts index 65c8a56a..7968db8b 100644 --- a/src/utils/focus/focusable.ts +++ b/src/utils/focus/focusable.ts @@ -19,7 +19,7 @@ export const FOCUSABLE_SELECTOR = [ /** * Determine if an element can be the target for `focusElement()`. * - * This does not necessarily mean that this element will be the `activeElement` + * 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 { From b3222206f7b4c8dd66d01955be47cdb2f71dd8a4 Mon Sep 17 00:00:00 2001 From: Philipp Fritsche Date: Fri, 19 Aug 2022 11:18:00 +0000 Subject: [PATCH 35/35] comment --- src/utils/focus/focusable.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/focus/focusable.ts b/src/utils/focus/focusable.ts index 7968db8b..d287be06 100644 --- a/src/utils/focus/focusable.ts +++ b/src/utils/focus/focusable.ts @@ -24,10 +24,10 @@ export const FOCUSABLE_SELECTOR = [ */ export function isFocusTarget(element: Element): element is HTMLElement { if (element.tagName.includes('-')) { - // custom elements without delegatesFocus are ignored + // custom elements without `delegatesFocus` are ignored return delegatesFocus(element) } - // elements that don't delegateFocus behave normal even if they're a shadow host + // elements without `delegatesFocus` behave normal even if they're a shadow host return delegatesFocus(element) || element.matches(FOCUSABLE_SELECTOR) }