-
Notifications
You must be signed in to change notification settings - Fork 252
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(pointer): move focus in shadow DOM #1038
Open
ph-fritsche
wants to merge
37
commits into
main
Choose a base branch
from
feat-shadowfocus
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
37 commits
Select commit
Hold shift + click to select a range
f424284
Potentially fix paste
Christian24 cb2b181
Fixed cut, paste and copy for open shadow dom
Christian24 7c53b38
Merge branch 'main' into main
Christian24 9771c98
Resolve conflicts
Christian24 79cb759
Update tests/dom/customElement.ts
Christian24 1d4dd4b
Moved Shadow DOM tests. Added focus and selection support for shadow …
Christian24 64e51e9
Added some tests for getActiveElement
Christian24 87b9e00
Added some tests for getActiveElement
Christian24 1a187e0
Fix type import
Christian24 dbed101
Update tests/utils/focus/getActiveElement.ts
Christian24 ddb525b
Fix type import
Christian24 e04b47b
Merge branch 'main' of https://github.com/Christian24/user-event into…
Christian24 3d00cb7
Add helper to define element. Remove describe block
Christian24 a651641
Use getActiveElementOrBody
Christian24 7f22d6a
Update tests/utils/focus/getActiveElement.ts
Christian24 0974cd0
Make focusing custom elements safer
Christian24 a6cebff
Update tests/utils/focus/getActiveElement.ts
Christian24 11b44c5
Remove test
Christian24 188b250
Removed types
Christian24 5f6d6d0
Add focus helper
Christian24 a981057
Delete test
Christian24 63d5586
Improve focus handling for setup
Christian24 67858c1
Improve focus handling for setup Add test for keyboard input on open …
Christian24 e05e521
Improve focus handling for setup
Christian24 0e07093
ease setting up tests
ph-fritsche 7a1e5c9
clean up import
ph-fritsche cbe1391
prevent accidental import of wrong util
ph-fritsche 6ecc7cc
add query helper
ph-fritsche 3950800
test: move focus per mousedown in shadow DOM
ph-fritsche 50a43ff
handle shadow hosts when moving focus
ph-fritsche 3d036b9
use util in test setup
ph-fritsche 63d0855
clean up
ph-fritsche 57585a2
clean up
ph-fritsche 640700d
refactor and guard against invalid `tabindex`
ph-fritsche 1ff9665
fix comment
ph-fritsche 22c0234
comment typo
ph-fritsche b322220
comment
ph-fritsche File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
// } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
export class HelloWorld extends HTMLElement { | ||
constructor() { | ||
super() | ||
this.attachShadow({ | ||
mode: 'open', | ||
delegatesFocus: this.hasAttribute('delegates'), | ||
}).innerHTML = `<p>Hello, World!</p>` | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
export class ShadowHost extends HTMLElement { | ||
constructor() { | ||
super() | ||
|
||
this.attachShadow({ | ||
mode: 'open', | ||
delegatesFocus: true, | ||
}).innerHTML = String(this.getAttribute('innerHTML')) | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we document this and related gotchas in the docs?