Skip to content

Commit

Permalink
improve support for nested shadow DOMs
Browse files Browse the repository at this point in the history
  • Loading branch information
Victor Babel committed Mar 28, 2022
1 parent de2edd6 commit 6aa3984
Show file tree
Hide file tree
Showing 11 changed files with 253 additions and 27 deletions.
2 changes: 1 addition & 1 deletion .size-limit
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[
{
path: "dist/es2015/index.js",
limit: "2.58 kB"
limit: "2.591 kB"
}
]
4 changes: 2 additions & 2 deletions .size.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[
{
"name": "dist/es2015/index.js",
"passed": true,
"size": 2641
"passed": false,
"size": 2653
}
]
97 changes: 97 additions & 0 deletions __tests__/focusInside.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,101 @@ describe('smoke', () => {
expect(focusInside([querySelector('#d3'), querySelector('#d1')])).toBe(true);
});
});

const createShadowTest = (nested?: boolean) => {
const html = `
<div id="app">
<div id="nonshadow">
<input />
<button>I am a button</button>
</div>
<div id="shadowdom"></div>
</div>`;
const shadowHtml = `
<div id="first"></div>
<button id="firstBtn">first button</button>
<div id="last">
<button id="secondBtn">second button</button>
</div>
`;
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);
});
});
});
114 changes: 114 additions & 0 deletions __tests__/focusIsHidden.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { focusIsHidden, constants } from '../src';

describe('focusIsHidden', () => {
const setupTest = () => {
document.body.innerHTML = `
<div ${constants.FOCUS_ALLOW}>
<button id="focus-hidden"></button>
</div>
<button id="focus-not-hidden"></button>
`;
};

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 = `
<div id="app">
<div id="nonshadow">
<input />
<button>I am a button</button>
</div>
<div id="shadowdom"></div>
</div>`;
const shadowHtml = `
<div id="first"></div>
<button id="firstBtn">first button</button>
<div id="last">
<button id="secondBtn">second button</button>
</div>
`;
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);
});
});
11 changes: 9 additions & 2 deletions __tests__/shadow-dom.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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,
});
}
}

Expand Down
6 changes: 2 additions & 4 deletions src/focusInside.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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));
};
4 changes: 3 additions & 1 deletion src/focusIsHidden.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FOCUS_ALLOW } from './constants';
import { contains } from './utils/DOMutils';
import { toArray } from './utils/array';
import { getActiveElement } from './utils/getActiveElement';

Expand All @@ -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));
};
12 changes: 1 addition & 11 deletions src/sibling.ts
Original file line number Diff line number Diff line change
@@ -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)) {
Expand Down
11 changes: 11 additions & 0 deletions src/utils/DOMutils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
);
};
14 changes: 10 additions & 4 deletions src/utils/getActiveElement.ts
Original file line number Diff line number Diff line change
@@ -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
};
5 changes: 3 additions & 2 deletions src/utils/parenting.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { parentAutofocusables } from './DOMutils';
import { contains } from './DOMutils';
import { asArray } from './array';
import { VisibilityCache } from './is';

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;
Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit 6aa3984

Please sign in to comment.