From de2edd61c19f1ea99f988f5521454b94d1a658a3 Mon Sep 17 00:00:00 2001 From: Victor Babel Date: Fri, 25 Mar 2022 13:27:18 -0700 Subject: [PATCH 1/3] Improve Shadow DOM support for custom web components: Search shadow roots for focusable elements in `getFocusables` Search shadow roots for element containment in `getRelativeFocusable` --- .size-limit | 2 +- .size.json | 2 +- __tests__/shadow-dom.spec.ts | 145 ++++++++++++++++++++++++++++++++--- src/sibling.ts | 12 ++- src/utils/tabUtils.ts | 16 +++- 5 files changed, 161 insertions(+), 16 deletions(-) diff --git a/.size-limit b/.size-limit index ac14e4c..365c021 100644 --- a/.size-limit +++ b/.size-limit @@ -1,6 +1,6 @@ [ { path: "dist/es2015/index.js", - limit: "2.5 kB" + limit: "2.58 kB" } ] diff --git a/.size.json b/.size.json index 635c8b2..46eff35 100644 --- a/.size.json +++ b/.size.json @@ -2,6 +2,6 @@ { "name": "dist/es2015/index.js", "passed": true, - "size": 2558 + "size": 2641 } ] diff --git a/__tests__/shadow-dom.spec.ts b/__tests__/shadow-dom.spec.ts index 54fbc2a..c1cb71a 100644 --- a/__tests__/shadow-dom.spec.ts +++ b/__tests__/shadow-dom.spec.ts @@ -1,11 +1,13 @@ -import { focusMerge } from '../src'; +import { focusMerge, focusNextElement, focusPrevElement } from '../src'; describe('shadow dow ', () => { afterEach(() => { document.getElementsByTagName('html')[0].innerHTML = ''; }); + it('supports detached elements', () => { document.body.innerHTML = `
`; + const frag = document.createDocumentFragment(); const button = document.createElement('button'); frag.appendChild(button); @@ -29,23 +31,53 @@ describe('shadow dow ', () => {
`; document.body.innerHTML = html; + const shadowContainer = document.getElementById('shadowdom') as HTMLElement; const root = shadowContainer.attachShadow({ mode: 'open' }); const shadowDiv = document.createElement('div'); shadowDiv.innerHTML = shadowHtml; root.appendChild(shadowDiv); + const firstBtn = root.getElementById('firstBtn'); + expect(focusMerge(shadowDiv, null)).toEqual({ node: firstBtn, }); }); - // customElements are not supported - it.skip('web components dom element', () => { + it('web components dom element', () => { // source: https://github.com/pearofducks/focus-lock-reproduction expect.assertions(1); - class ReproElement extends HTMLElement { + class FocusWithinShadow extends HTMLElement { + public connectedCallback() { + const html = ` +
+ + +
+ `; + const shadow = this.attachShadow({ mode: 'open' }); + shadow.innerHTML = html; + + expect(focusMerge(document.body, null)).toEqual({ + node: shadow.querySelector('input'), + }); + } + } + + customElements.define('focus-within-shadow', FocusWithinShadow); + + document.body.innerHTML = ` + + + + `; + const shadow = this.attachShadow({ mode: 'open' }); + shadow.innerHTML = html; + + const shadowInput = shadow.querySelector('input') as HTMLInputElement; + const shadowButton = shadow.querySelector('button') as HTMLButtonElement; + const input = document.querySelector('input') as HTMLInputElement; + const button = document.querySelector('button') as HTMLButtonElement; + + focusMerge(document.body, null)?.node?.focus(); + + expect(document.activeElement).toBe(input); + + focusNextElement(input); + + expect(document.activeElement).toBe(button); + + focusNextElement(button); + + expect(document.activeElement?.shadowRoot?.activeElement).toBe(shadowButton); + + focusNextElement(shadowButton); + + expect(document.activeElement?.shadowRoot?.activeElement).toBe(shadowInput); + } + } + + customElements.define('focus-next-ooo', FocusNextOOO); + + document.body.innerHTML = ` + + + + `; + }); + + it('focusPrevElement w/ web components respects out of order tabIndex', () => { + expect.assertions(4); + + class FocusPrevOOO extends HTMLElement { + public connectedCallback() { + const html = ` +
+ + +
+ `; + const shadow = this.attachShadow({ mode: 'open' }); + shadow.innerHTML = html; + + const shadowInput = shadow.querySelector('input') as HTMLInputElement; + const shadowButton = shadow.querySelector('button') as HTMLButtonElement; + const input = document.querySelector('input') as HTMLInputElement; + const button = document.querySelector('button') as HTMLButtonElement; + + focusMerge(document.body, null)?.node?.focus(); + + expect(document.activeElement).toBe(input); + + focusPrevElement(input); + + expect(document.activeElement?.shadowRoot?.activeElement).toBe(shadowInput); + + focusPrevElement(shadowInput); + + expect(document.activeElement?.shadowRoot?.activeElement).toBe(shadowButton); + + focusPrevElement(shadowButton); + + expect(document.activeElement).toBe(button); + } } - document.body.innerHTML = ``; + customElements.define('focus-prev-ooo', FocusPrevOOO); + + document.body.innerHTML = ` + + + + `; }); }); diff --git a/src/sibling.ts b/src/sibling.ts index 3ae191f..06bacd7 100644 --- a/src/sibling.ts +++ b/src/sibling.ts @@ -1,8 +1,18 @@ import { focusOn } from './setFocus'; import { getTabbableNodes } from './utils/DOMutils'; +// need to search within shadowRoots (and potentially nested shadowRoots) for +// the element +const contains = (scope: Element, element: Element): boolean => { + return ( + scope.contains(element) || + (scope as HTMLElement).shadowRoot?.contains(element) || + Array.from(scope.children).some((child) => contains(child, element)) + ); +}; + const getRelativeFocusable = (element: Element, scope: HTMLElement | HTMLDocument) => { - if (!element || !scope || !scope.contains(element)) { + if (!element || !scope || !contains(scope as Element, element)) { return {}; } diff --git a/src/utils/tabUtils.ts b/src/utils/tabUtils.ts index 986b916..a9af11b 100644 --- a/src/utils/tabUtils.ts +++ b/src/utils/tabUtils.ts @@ -5,13 +5,23 @@ import { tabbables } from './tabbables'; const queryTabbables = tabbables.join(','); const queryGuardTabbables = `${queryTabbables}, [data-focus-guard]`; +const getFocusablesWithShadowDom = (parent: Element, withGuards?: boolean): HTMLElement[] => + toArray(parent.shadowRoot?.children || parent.children).reduce( + (acc, child) => + acc.concat( + child.matches(withGuards ? queryGuardTabbables : queryTabbables) ? [child as HTMLElement] : [], + getFocusablesWithShadowDom(child) + ), + [] as HTMLElement[] + ); + export const getFocusables = (parents: Element[], withGuards?: boolean): HTMLElement[] => parents.reduce( (acc, parent) => acc.concat( - // add all tabbables inside - toArray(parent.querySelectorAll(withGuards ? queryGuardTabbables : queryTabbables)), - // add if node is tabbale itself + // add all tabbables inside and within shadow DOMs in DOM order + getFocusablesWithShadowDom(parent, withGuards), + // add if node is tabbable itself parent.parentNode ? toArray(parent.parentNode.querySelectorAll(queryTabbables)).filter((node) => node === parent) : [] From 6aa398432aa2f8a6a356461b3cd3358b93b174cf Mon Sep 17 00:00:00 2001 From: Victor Babel Date: Mon, 28 Mar 2022 14:00:23 -0700 Subject: [PATCH 2/3] improve support for nested shadow DOMs --- .size-limit | 2 +- .size.json | 4 +- __tests__/focusInside.spec.ts | 97 +++++++++++++++++++++++++++ __tests__/focusIsHidden.spec.ts | 114 ++++++++++++++++++++++++++++++++ __tests__/shadow-dom.spec.ts | 11 ++- src/focusInside.ts | 6 +- src/focusIsHidden.ts | 4 +- src/sibling.ts | 12 +--- src/utils/DOMutils.ts | 11 +++ src/utils/getActiveElement.ts | 14 ++-- src/utils/parenting.ts | 5 +- 11 files changed, 253 insertions(+), 27 deletions(-) create mode 100644 __tests__/focusIsHidden.spec.ts diff --git a/.size-limit b/.size-limit index 365c021..3b5d3df 100644 --- a/.size-limit +++ b/.size-limit @@ -1,6 +1,6 @@ [ { path: "dist/es2015/index.js", - limit: "2.58 kB" + limit: "2.591 kB" } ] diff --git a/.size.json b/.size.json index 46eff35..5078c68 100644 --- a/.size.json +++ b/.size.json @@ -1,7 +1,7 @@ [ { "name": "dist/es2015/index.js", - "passed": true, - "size": 2641 + "passed": false, + "size": 2653 } ] diff --git a/__tests__/focusInside.spec.ts b/__tests__/focusInside.spec.ts index e4edca2..5393fa6 100644 --- a/__tests__/focusInside.spec.ts +++ b/__tests__/focusInside.spec.ts @@ -60,4 +60,101 @@ describe('smoke', () => { expect(focusInside([querySelector('#d3'), querySelector('#d1')])).toBe(true); }); }); + + const createShadowTest = (nested?: boolean) => { + const html = ` +
+
+ + +
+
+
`; + const shadowHtml = ` +
+ +
+ +
+ `; + document.body.innerHTML = html; + + const shadowContainer = document.getElementById('shadowdom') as HTMLElement; + const root = shadowContainer.attachShadow({ mode: 'open' }); + const shadowDiv = document.createElement('div'); + shadowDiv.innerHTML = shadowHtml; + root.appendChild(shadowDiv); + + if (nested) { + const firstDiv = root.querySelector('#first') as HTMLDivElement; + const nestedRoot = firstDiv.attachShadow({ mode: 'open' }); + const nestedShadowDiv = document.createElement('div'); + + nestedShadowDiv.innerHTML = shadowHtml; + nestedRoot.appendChild(nestedShadowDiv); + } + }; + + describe('with shadow dom', () => { + it('false when the focus is within a shadow dom not within the topNode', () => { + createShadowTest(); + + const nonShadowDiv = querySelector('#nonshadow'); + + const shadowBtn = querySelector('#shadowdom')?.shadowRoot?.querySelector('#firstBtn') as HTMLButtonElement; + + shadowBtn.focus(); + + expect(focusInside(document.body)).toBe(true); + expect(focusInside(nonShadowDiv)).toBe(false); + }); + + it('false when topNode is shadow sibling of focused node', () => { + createShadowTest(); + + const shadowHost = querySelector('#shadowdom'); + + const shadowBtn = shadowHost.shadowRoot?.querySelector('#firstBtn') as HTMLButtonElement; + const shadowDivLast = shadowHost.shadowRoot?.querySelector('#last') as HTMLDivElement; + + shadowBtn.focus(); + + expect(focusInside(document.body)).toBe(true); + expect(focusInside(shadowDivLast)).toBe(false); + }); + + it('true when focus is within shadow dom within topNode', () => { + createShadowTest(); + + const shadowHost = querySelector('#shadowdom'); + + const shadowDivLast = shadowHost.shadowRoot?.querySelector('#last') as HTMLDivElement; + const shadowBtn = shadowHost.shadowRoot?.querySelector('#secondBtn') as HTMLButtonElement; + + shadowBtn.focus(); + + expect(focusInside(document.body)).toBe(true); + expect(focusInside(shadowHost)).toBe(true); + expect(focusInside(shadowDivLast)).toBe(true); + }); + + it('true when focus is within nested shadow dom', () => { + createShadowTest(true); + + const shadowHost = querySelector('#shadowdom'); + const nestedShadowHost = shadowHost.shadowRoot?.querySelector('#first') as HTMLDivElement; + + const nestedShadowDiv = nestedShadowHost.shadowRoot?.querySelector('#first') as HTMLDivElement; + const nestedShadowDivLast = nestedShadowHost.shadowRoot?.querySelector('#last') as HTMLDivElement; + const nestedShadowButton = nestedShadowDivLast.querySelector('#secondBtn') as HTMLButtonElement; + + nestedShadowButton.focus(); + + expect(focusInside(document.body)).toBe(true); + expect(focusInside(shadowHost)).toBe(true); + expect(focusInside(nestedShadowHost)).toBe(true); + expect(focusInside(nestedShadowDiv)).toBe(false); + expect(focusInside(nestedShadowDivLast)).toBe(true); + }); + }); }); diff --git a/__tests__/focusIsHidden.spec.ts b/__tests__/focusIsHidden.spec.ts new file mode 100644 index 0000000..c59ea06 --- /dev/null +++ b/__tests__/focusIsHidden.spec.ts @@ -0,0 +1,114 @@ +import { focusIsHidden, constants } from '../src'; + +describe('focusIsHidden', () => { + const setupTest = () => { + document.body.innerHTML = ` +
+ +
+ + `; + }; + + it('returns true when the focused element is hidden', () => { + setupTest(); + + const button = document.querySelector('#focus-hidden') as HTMLButtonElement; + + button.focus(); + + expect(focusIsHidden()).toBe(true); + }); + + it('returns false when the focused element is not hidden', () => { + setupTest(); + + const button = document.querySelector('#focus-not-hidden') as HTMLButtonElement; + + button.focus(); + + expect(focusIsHidden()).toBe(false); + }); + + const setupShadowTest = (nested?: boolean) => { + const html = ` +
+
+ + +
+
+
`; + const shadowHtml = ` +
+ +
+ +
+ `; + document.body.innerHTML = html; + + const shadowContainer = document.getElementById('shadowdom') as HTMLElement; + const root = shadowContainer.attachShadow({ mode: 'open' }); + const shadowDiv = document.createElement('div'); + shadowDiv.innerHTML = shadowHtml; + root.appendChild(shadowDiv); + + if (nested) { + const firstDiv = root.querySelector('#first') as HTMLDivElement; + const nestedRoot = firstDiv.attachShadow({ mode: 'open' }); + const nestedShadowDiv = document.createElement('div'); + + nestedShadowDiv.innerHTML = shadowHtml; + nestedRoot.appendChild(nestedShadowDiv); + } + }; + + it('looks for focus within shadow doms', () => { + setupShadowTest(); + + const shadowHost = document.querySelector('#shadowdom') as HTMLDivElement; + const button = shadowHost.shadowRoot?.querySelector('#firstBtn') as HTMLButtonElement; + + button.focus(); + + expect(focusIsHidden()).toBe(false); + + shadowHost.setAttribute(constants.FOCUS_ALLOW, ''); + + expect(focusIsHidden()).toBe(true); + }); + + it('looks for focus within nested shadow doms', () => { + setupShadowTest(true); + + const shadowHost = document.querySelector('#shadowdom') as HTMLDivElement; + const nestedShadowHost = shadowHost.shadowRoot?.querySelector('#first') as HTMLDivElement; + const button = nestedShadowHost.shadowRoot?.querySelector('#firstBtn') as HTMLButtonElement; + + button.focus(); + + expect(focusIsHidden()).toBe(false); + + shadowHost.setAttribute(constants.FOCUS_ALLOW, ''); + + expect(focusIsHidden()).toBe(true); + }); + + it('does not support marking shadow members as FOCUS_ALLOW', () => { + setupShadowTest(); + + const shadowHost = document.querySelector('#shadowdom') as HTMLDivElement; + const shadowDiv = shadowHost.shadowRoot?.querySelector('#last') as HTMLDivElement; + const button = shadowDiv.children[0] as HTMLButtonElement; + + button.focus(); + + expect(focusIsHidden()).toBe(false); + + // Setting elements within shadow doms as FOCUS_ALLOW not supported + shadowDiv.setAttribute(constants.FOCUS_ALLOW, ''); + + expect(focusIsHidden()).toBe(false); + }); +}); diff --git a/__tests__/shadow-dom.spec.ts b/__tests__/shadow-dom.spec.ts index c1cb71a..e9d57e1 100644 --- a/__tests__/shadow-dom.spec.ts +++ b/__tests__/shadow-dom.spec.ts @@ -74,8 +74,8 @@ describe('shadow dow ', () => { `; }); - it('web components respect tabIndex', () => { - expect.assertions(1); + it.only('web components respect tabIndex', () => { + expect.assertions(2); class FocusOutsideShadow extends HTMLElement { public connectedCallback() { @@ -88,9 +88,16 @@ describe('shadow dow ', () => { const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = html; + const input = shadow.querySelector('input') as HTMLInputElement; + const button = shadow.querySelector('button') as HTMLButtonElement; + expect(focusMerge(document.body, null)).toEqual({ node: document.querySelector('#focused'), }); + + expect(focusMerge([input, button], null)).toEqual({ + node: input, + }); } } diff --git a/src/focusInside.ts b/src/focusInside.ts index 1e8b5e9..548ed25 100644 --- a/src/focusInside.ts +++ b/src/focusInside.ts @@ -1,3 +1,4 @@ +import { contains } from './utils/DOMutils'; import { getAllAffectedNodes } from './utils/all-affected'; import { toArray } from './utils/array'; import { getActiveElement } from './utils/getActiveElement'; @@ -14,8 +15,5 @@ export const focusInside = (topNode: HTMLElement | HTMLElement[]): boolean => { return false; } - return getAllAffectedNodes(topNode).reduce( - (result, node) => result || node.contains(activeElement) || focusInsideIframe(node), - false as boolean - ); + return getAllAffectedNodes(topNode).some((node) => contains(node, activeElement) || focusInsideIframe(node)); }; diff --git a/src/focusIsHidden.ts b/src/focusIsHidden.ts index cf97642..398d5b5 100644 --- a/src/focusIsHidden.ts +++ b/src/focusIsHidden.ts @@ -1,4 +1,5 @@ import { FOCUS_ALLOW } from './constants'; +import { contains } from './utils/DOMutils'; import { toArray } from './utils/array'; import { getActiveElement } from './utils/getActiveElement'; @@ -14,5 +15,6 @@ export const focusIsHidden = (): boolean => { return false; } - return toArray(document.querySelectorAll(`[${FOCUS_ALLOW}]`)).some((node) => node.contains(activeElement)); + // this does not support setting FOCUS_ALLOW within shadow dom + return toArray(document.querySelectorAll(`[${FOCUS_ALLOW}]`)).some((node) => contains(node, activeElement)); }; diff --git a/src/sibling.ts b/src/sibling.ts index 06bacd7..928d2df 100644 --- a/src/sibling.ts +++ b/src/sibling.ts @@ -1,15 +1,5 @@ import { focusOn } from './setFocus'; -import { getTabbableNodes } from './utils/DOMutils'; - -// need to search within shadowRoots (and potentially nested shadowRoots) for -// the element -const contains = (scope: Element, element: Element): boolean => { - return ( - scope.contains(element) || - (scope as HTMLElement).shadowRoot?.contains(element) || - Array.from(scope.children).some((child) => contains(child, element)) - ); -}; +import { getTabbableNodes, contains } from './utils/DOMutils'; const getRelativeFocusable = (element: Element, scope: HTMLElement | HTMLDocument) => { if (!element || !scope || !contains(scope as Element, element)) { diff --git a/src/utils/DOMutils.ts b/src/utils/DOMutils.ts index 6cd7960..98b4b80 100644 --- a/src/utils/DOMutils.ts +++ b/src/utils/DOMutils.ts @@ -28,3 +28,14 @@ export const getAllTabbableNodes = (topNodes: Element[], visibilityCache: Visibi export const parentAutofocusables = (topNode: Element, visibilityCache: VisibilityCache): Element[] => filterFocusable(getParentAutofocusables(topNode), visibilityCache); + +/* + * Determines if element is contained in scope, including nested shadow DOMs + */ +export const contains = (scope: Element | ShadowRoot, element: Element): boolean => { + return ( + ((scope as HTMLElement).shadowRoot + ? contains((scope as HTMLElement).shadowRoot as ShadowRoot, element) + : scope.contains(element)) || Array.from(scope.children).some((child) => contains(child, element)) + ); +}; diff --git a/src/utils/getActiveElement.ts b/src/utils/getActiveElement.ts index 1760e86..7444890 100644 --- a/src/utils/getActiveElement.ts +++ b/src/utils/getActiveElement.ts @@ -1,13 +1,19 @@ +const getNestedShadowActiveElement = (shadowRoot: ShadowRoot): HTMLElement | undefined => + shadowRoot.activeElement + ? shadowRoot.activeElement.shadowRoot + ? getNestedShadowActiveElement(shadowRoot.activeElement.shadowRoot) + : (shadowRoot.activeElement as HTMLElement) + : undefined; + /** - * returns active element from document or from shadowdom + * returns active element from document or from nested shadowdoms */ export const getActiveElement = (): HTMLElement | undefined => { return ( document.activeElement ? document.activeElement.shadowRoot - ? document.activeElement.shadowRoot.activeElement + ? getNestedShadowActiveElement(document.activeElement.shadowRoot) : document.activeElement : undefined - ) as // eslint-disable-next-line @typescript-eslint/no-explicit-any - any; + ) as any; // eslint-disable-next-line @typescript-eslint/no-explicit-any }; diff --git a/src/utils/parenting.ts b/src/utils/parenting.ts index ddd7008..bca511c 100644 --- a/src/utils/parenting.ts +++ b/src/utils/parenting.ts @@ -1,4 +1,5 @@ import { parentAutofocusables } from './DOMutils'; +import { contains } from './DOMutils'; import { asArray } from './array'; import { VisibilityCache } from './is'; @@ -6,7 +7,7 @@ const getParents = (node: Element, parents: Element[] = []): Element[] => { parents.push(node); if (node.parentNode) { - getParents(node.parentNode as HTMLElement, parents); + getParents((node.parentNode as ShadowRoot).host || node.parentNode, parents); } return parents; @@ -51,7 +52,7 @@ export const getTopCommonParent = ( const common = getCommonParent(activeElement, subEntry); if (common) { - if (!topCommon || common.contains(topCommon)) { + if (!topCommon || contains(common, topCommon)) { topCommon = common; } else { topCommon = getCommonParent(common, topCommon); From 70ec30d33712f296b219329c3862a742b01678ca Mon Sep 17 00:00:00 2001 From: Victor Babel Date: Fri, 1 Apr 2022 10:11:56 -0700 Subject: [PATCH 3/3] remove it.only in shadow-dom spec --- __tests__/shadow-dom.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/shadow-dom.spec.ts b/__tests__/shadow-dom.spec.ts index e9d57e1..680817d 100644 --- a/__tests__/shadow-dom.spec.ts +++ b/__tests__/shadow-dom.spec.ts @@ -74,7 +74,7 @@ describe('shadow dow ', () => { `; }); - it.only('web components respect tabIndex', () => { + it('web components respect tabIndex', () => { expect.assertions(2); class FocusOutsideShadow extends HTMLElement {