Skip to content

Commit

Permalink
feat(shadow-dom): getAllLiveRegions to traverse down elements with …
Browse files Browse the repository at this point in the history
…shadowRoot
  • Loading branch information
AriPerkkio committed Feb 12, 2022
1 parent 8471968 commit 0b17501
Show file tree
Hide file tree
Showing 5 changed files with 214 additions and 3 deletions.
4 changes: 4 additions & 0 deletions src/dom-node-safe-guards.ts
Expand Up @@ -9,3 +9,7 @@ export function isElement(node: Node | null): node is Element {
export function isShadowRoot(node: Node | null): node is ShadowRoot {
return node != null && node instanceof ShadowRoot;
}

export function isDocument(node: Node | null): node is Document {
return node != null && node.nodeType === Node.DOCUMENT_NODE;
}
84 changes: 83 additions & 1 deletion src/queries.ts
@@ -1,5 +1,5 @@
import { getConfig } from './config';
import { isElement, isShadowRoot } from './dom-node-safe-guards';
import { isDocument, isElement, isShadowRoot } from './dom-node-safe-guards';

/**
* `Element.closest` which traverses tree up when `ShadowRoot` is encountered
Expand Down Expand Up @@ -48,3 +48,85 @@ export function getChildNodes(node: Node): Node['childNodes'] {

return node.childNodes;
}

/**
* `querySelectorAll` which includes all contents of all `ShadowRoot`'s.
* Note that return type is directly `Element[]` instead of `NodeListOf`.
*/
export function querySelectorAll(
context: Document | Element,
...args: Parameters<typeof context['querySelectorAll']>
): Element[] {
if (!getConfig().includeShadowDom) {
return Array.from(context.querySelectorAll(...args));
}

const roots = [context, ...findShadowRoots([context])];

return roots.reduce<Element[]>(
(all, root) => [...all, ...root.querySelectorAll(...args)],
[]
);
}

/**
* Finds `ShadowRoot`'s and their nested `ShadowRoot`'s
* - This is highly inspired by Cypress: https://github.com/cypress-io/cypress/blob/develop/packages/driver/src/dom/elements/shadow.ts
*/
function findShadowRoots(
nodes: Node[],
shadowRoots: ShadowRoot[] = []
): ShadowRoot[] {
if (nodes.length === 0) return shadowRoots;

// Find new nested shadow roots
const rootsFromThisLevel = nodes.reduce<ShadowRoot[]>(
(all, node) => [...all, ...findShadowRootsOfNode(node)],
[]
);

// Check whether newly found shadow roots have nested shadow roots
return findShadowRoots(rootsFromThisLevel, [
...shadowRoots,
...rootsFromThisLevel,
]);
}

/**
* Finds all `ShadowRoot`'s of given node. Does not traverse nested `ShadowRoot`'s.
*/
function findShadowRootsOfNode(root: Node): ShadowRoot[] {
const doc = root.getRootNode({ composed: true });
const shadowRoots: ShadowRoot[] = [];

if (!isDocument(doc)) return shadowRoots;

if (isElement(root) && root.shadowRoot) {
shadowRoots.push(root.shadowRoot);
}

const treeWalker = doc.createTreeWalker(
root,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT,
{ acceptNode: acceptNodesWithShadowRoot }
);

function collectRoots(roots: ShadowRoot[]): ShadowRoot[] {
const nextNode = treeWalker.nextNode();

if (!isElement(nextNode)) return roots;
if (!nextNode.shadowRoot) return roots;

return collectRoots([...roots, nextNode.shadowRoot]);
}

return collectRoots(shadowRoots);
}

function acceptNodesWithShadowRoot(node: Node): number {
if (isElement(node) && node.shadowRoot) {
return NodeFilter.FILTER_ACCEPT;
}

return NodeFilter.FILTER_SKIP;
}
2 changes: 1 addition & 1 deletion src/utils.ts
Expand Up @@ -22,7 +22,7 @@ const LIVE_REGION_QUERY = [
const HIDDEN_QUERY = '[aria-hidden="true"]';

export function getAllLiveRegions(context: Document | Element): Element[] {
const liveRegions = Array.from(context.querySelectorAll(LIVE_REGION_QUERY));
const liveRegions = queries.querySelectorAll(context, LIVE_REGION_QUERY);

// Check whether given `context` is also a live region
if (
Expand Down
30 changes: 29 additions & 1 deletion test/capture-announcements.test.ts
@@ -1,6 +1,6 @@
import CaptureAnnouncements from '../src';
import { __PrivateUnstableAPI } from '../src/capture-announcements';
import { getConfig } from '../src/config';
import { configure, getConfig } from '../src/config';
import {
appendToRoot,
POLITE_CASES,
Expand Down Expand Up @@ -613,6 +613,34 @@ describe('element tracking', () => {
expect(liveRegions.size).toBe(1);
expect(liveRegions.has(element)).toBe(true);
});

test('element in shadow dom is tracked', () => {
configure({ includeShadowDom: true });

const region = document.createElement('div');
region.setAttribute('aria-live', 'polite');

element.attachShadow({ mode: 'open' });
element.shadowRoot!.appendChild(region);
appendToRoot(element);

expect(liveRegions.size).toBe(1);
expect(liveRegions.has(region)).toBe(true);
});

test('does not track shadow doms when config.includeShadowDom is false', () => {
configure({ includeShadowDom: false });

const region = document.createElement('div');
region.setAttribute('aria-live', 'polite');

element.attachShadow({ mode: 'open' });
element.shadowRoot!.appendChild(region);
appendToRoot(element);

expect(liveRegions.size).toBe(0);
expect(liveRegions.has(region)).toBe(false);
});
});

describe('config', () => {
Expand Down
97 changes: 97 additions & 0 deletions test/utils.test.ts
@@ -1,5 +1,6 @@
import { configure } from '../src/config';
import {
getAllLiveRegions,
getClosestElement,
getClosestLiveRegion,
getTextContent,
Expand All @@ -8,6 +9,102 @@ import {
} from '../src/utils';
import { appendToRoot } from './utils';

describe('getAllLiveRegions', () => {
let root: HTMLElement;
let element: HTMLElement;

beforeEach(() => {
element = document.createElement('div');
root = document.getElementById('root')!;

root.appendChild(element);
});

test.each(['status', 'log', 'alert'])(
'returns element with [role="%s"]',
role => {
element.setAttribute('role', role);

expect(getAllLiveRegions(root)).toContain(element);
}
);

test.each(['polite', 'assertive'])(
'returns element with [aria-live="%s"]',
ariaLive => {
element.setAttribute('aria-live', ariaLive);

expect(getAllLiveRegions(root)).toContain(element);
}
);

test('returns element with output', () => {
const output = document.createElement('output');
root.appendChild(output);

expect(getAllLiveRegions(root)).toContain(output);
});

test('ignores element with aria-live="off"', () => {
element.setAttribute('aria-live', 'off');

expect(getAllLiveRegions(root)).not.toContain(element);
});

test.each(['marquee', 'timer'])(
'ignores element with [role="%s"]',
role => {
element.setAttribute('role', role);

expect(getAllLiveRegions(root)).not.toContain(element);
}
);

test('returns live region from the shadow root', () => {
configure({ includeShadowDom: true });

const region = document.createElement('div');
region.setAttribute('aria-live', 'polite');

const shadowRoot = element.attachShadow({ mode: 'open' });
shadowRoot.appendChild(region);

expect(getAllLiveRegions(root)).toContain(region);
});

test('returns live region from nested the shadow roots', () => {
configure({ includeShadowDom: true });

const shadowRoot = element.attachShadow({ mode: 'open' });

const first = document.createElement('div');
const second = document.createElement('div');
const third = document.createElement('div');
const fourth = document.createElement('div');

shadowRoot.appendChild(first);
first.attachShadow({ mode: 'open' }).appendChild(second);
second.attachShadow({ mode: 'open' }).appendChild(third);
third.attachShadow({ mode: 'open' }).appendChild(fourth);
third.setAttribute('aria-live', 'polite');

expect(getAllLiveRegions(root)).toContain(third);
});

test('does not traverse shadow dom when config.includeShadowDom is false', () => {
configure({ includeShadowDom: false });

const region = document.createElement('div');
region.setAttribute('aria-live', 'polite');

const shadowRoot = element.attachShadow({ mode: 'open' });
shadowRoot.appendChild(region);

expect(getAllLiveRegions(root)).not.toContain(region);
expect(getAllLiveRegions(root)).toHaveLength(0);
});
});

describe('getClosestLiveRegion', () => {
test('returns itself when live region', () => {
const element = document.createElement('div');
Expand Down

0 comments on commit 0b17501

Please sign in to comment.