-
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(keyboard): support shadow DOM in tab order #1040
base: main
Are you sure you want to change the base?
Changes from all commits
f424284
cb2b181
7c53b38
9771c98
79cb759
1d4dd4b
64e51e9
87b9e00
1a187e0
dbed101
ddb525b
e04b47b
3d00cb7
a651641
7f22d6a
0974cd0
a6cebff
11b44c5
188b250
5f6d6d0
a981057
63d5586
67858c1
e05e521
0e07093
7a1e5c9
cbe1391
6ecc7cc
3950800
50a43ff
3d036b9
63d0855
57585a2
640700d
1ff9665
22c0234
b322220
7756374
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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('-')) { | ||
// 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('-') && | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 && | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. wouldn't |
||
(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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would prefer a more describing name than |
||
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 | ||
} |
This file was deleted.
This file was deleted.
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>` | ||
} | ||
} |
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) | ||
} | ||
}) | ||
} |
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')) | ||
} | ||
} |
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.
I dunno but I think having this a separate helper function might be helpful. And makes it more clear as well.