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((