Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(keyboard): support shadow DOM in tab order #1040

Open
wants to merge 38 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
f424284
Potentially fix paste
Christian24 Aug 7, 2022
cb2b181
Fixed cut, paste and copy for open shadow dom
Christian24 Aug 8, 2022
7c53b38
Merge branch 'main' into main
Christian24 Aug 12, 2022
9771c98
Resolve conflicts
Christian24 Aug 12, 2022
79cb759
Update tests/dom/customElement.ts
Christian24 Aug 14, 2022
1d4dd4b
Moved Shadow DOM tests. Added focus and selection support for shadow …
Christian24 Aug 14, 2022
64e51e9
Added some tests for getActiveElement
Christian24 Aug 14, 2022
87b9e00
Added some tests for getActiveElement
Christian24 Aug 14, 2022
1a187e0
Fix type import
Christian24 Aug 15, 2022
dbed101
Update tests/utils/focus/getActiveElement.ts
Christian24 Aug 15, 2022
ddb525b
Fix type import
Christian24 Aug 15, 2022
e04b47b
Merge branch 'main' of https://github.com/Christian24/user-event into…
Christian24 Aug 15, 2022
3d00cb7
Add helper to define element. Remove describe block
Christian24 Aug 15, 2022
a651641
Use getActiveElementOrBody
Christian24 Aug 15, 2022
7f22d6a
Update tests/utils/focus/getActiveElement.ts
Christian24 Aug 15, 2022
0974cd0
Make focusing custom elements safer
Christian24 Aug 15, 2022
a6cebff
Update tests/utils/focus/getActiveElement.ts
Christian24 Aug 16, 2022
11b44c5
Remove test
Christian24 Aug 16, 2022
188b250
Removed types
Christian24 Aug 16, 2022
5f6d6d0
Add focus helper
Christian24 Aug 16, 2022
a981057
Delete test
Christian24 Aug 16, 2022
63d5586
Improve focus handling for setup
Christian24 Aug 16, 2022
67858c1
Improve focus handling for setup Add test for keyboard input on open …
Christian24 Aug 16, 2022
e05e521
Improve focus handling for setup
Christian24 Aug 16, 2022
0e07093
ease setting up tests
ph-fritsche Aug 18, 2022
7a1e5c9
clean up import
ph-fritsche Aug 18, 2022
cbe1391
prevent accidental import of wrong util
ph-fritsche Aug 18, 2022
6ecc7cc
add query helper
ph-fritsche Aug 18, 2022
3950800
test: move focus per mousedown in shadow DOM
ph-fritsche Aug 18, 2022
50a43ff
handle shadow hosts when moving focus
ph-fritsche Aug 19, 2022
3d036b9
use util in test setup
ph-fritsche Aug 19, 2022
63d0855
clean up
ph-fritsche Aug 19, 2022
57585a2
clean up
ph-fritsche Aug 19, 2022
640700d
refactor and guard against invalid `tabindex`
ph-fritsche Aug 19, 2022
1ff9665
fix comment
ph-fritsche Aug 19, 2022
22c0234
comment typo
ph-fritsche Aug 19, 2022
b322220
comment
ph-fritsche Aug 19, 2022
7756374
feat(keyboard): support shadow DOM in tab order
ph-fritsche Aug 19, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions 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)

Expand Down
4 changes: 2 additions & 2 deletions 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)

Expand Down
3 changes: 2 additions & 1 deletion src/clipboard/paste.ts
@@ -1,6 +1,7 @@
import type {Instance} from '../setup'
import {
createDataTransfer,
getActiveElementOrBody,
getWindow,
readDataTransferFromClipboard,
} from '../utils'
Expand All @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/event/behavior/click.ts
Expand Up @@ -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))
}
Expand Down
11 changes: 2 additions & 9 deletions 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,
Expand Down Expand Up @@ -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)
}
},
}
Expand Down
62 changes: 52 additions & 10 deletions 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)
}
26 changes: 12 additions & 14 deletions 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 */
Expand All @@ -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)
}
}
85 changes: 85 additions & 0 deletions src/utils/focus/focusable.ts
@@ -0,0 +1,85 @@
/**
* 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('-')) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dunno but I think having this a separate helper function might be helpful. And makes it more clear as well.

// 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('-') &&

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above.

!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 &&

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't element.shadowRoot be sufficient here? The !! shouldn't make a difference right?

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer a more describing name than f. Maybe focusable or focusableEl?

if (f) {
return f
}
}
}
}

/**
* 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
}
4 changes: 1 addition & 3 deletions 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) {
Expand Down
4 changes: 2 additions & 2 deletions src/utils/focus/getTabDestination.ts
@@ -1,11 +1,11 @@
import {isDisabled} from '../misc/isDisabled'
import {isElementType} from '../misc/isElementType'
import {isVisible} from '../misc/isVisible'
import {FOCUSABLE_SELECTOR} from './selector'
import {findAllFocusable} from './focusable'

export function getTabDestination(activeElement: Element, shift: boolean) {
const document = activeElement.ownerDocument
const focusableElements = document.querySelectorAll(FOCUSABLE_SELECTOR)
const focusableElements = findAllFocusable(document.body)

const enabledElements = Array.from(focusableElements).filter(
el =>
Expand Down
5 changes: 0 additions & 5 deletions src/utils/focus/isFocusable.ts

This file was deleted.

10 changes: 0 additions & 10 deletions src/utils/focus/selector.ts

This file was deleted.

3 changes: 1 addition & 2 deletions src/utils/index.ts
Expand Up @@ -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'

Expand Down
9 changes: 9 additions & 0 deletions 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 = `<p>Hello, World!</p>`
}
}
25 changes: 25 additions & 0 deletions 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)
}
})
}
10 changes: 10 additions & 0 deletions 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'))
}
}