diff --git a/lib/checks/lists/invalid-children-evaluate.js b/lib/checks/lists/invalid-children-evaluate.js index 684e2eb657..b995ad1bed 100644 --- a/lib/checks/lists/invalid-children-evaluate.js +++ b/lib/checks/lists/invalid-children-evaluate.js @@ -1,4 +1,7 @@ -import { isVisibleToScreenReaders } from '../../commons/dom'; +import { + isVisibleToScreenReaders, + getVisibleShadowChildren +} from '../../commons/dom'; import { getExplicitRole } from '../../commons/aria'; export default function invalidChildrenEvaluate( @@ -61,9 +64,30 @@ function getInvalidSelector( const role = getExplicitRole(vChild); if (role) { return validRoles.includes(role) ? false : selector + `[role=${role}]`; - } else { - return validNodeNames.includes(nodeName) ? false : selector + nodeName; } + + if (validNodeNames.includes(nodeName)) { + return false; + } + + // Check if a shadow host's shadow DOM children are all valid. + // This handles web components that wrap a valid element in their shadow DOM + // (e.g. whose shadow DOM renders
  • ). + const visibleShadowChildren = getVisibleShadowChildren(vChild); + if (visibleShadowChildren) { + const allShadowChildrenValid = visibleShadowChildren.every(shadowChild => { + const shadowRole = getExplicitRole(shadowChild); + if (shadowRole) { + return validRoles.includes(shadowRole); + } + return validNodeNames.includes(shadowChild.props.nodeName); + }); + if (allShadowChildrenValid) { + return false; + } + } + + return selector + nodeName; } function isDivGroup(vNode) { diff --git a/lib/checks/lists/listitem-evaluate.js b/lib/checks/lists/listitem-evaluate.js index dde5dcb4f4..4276fc6d0e 100644 --- a/lib/checks/lists/listitem-evaluate.js +++ b/lib/checks/lists/listitem-evaluate.js @@ -1,4 +1,5 @@ import { isValidRole, getExplicitRole } from '../../commons/aria'; +import { isShadowRoot } from '../../core/utils'; export default function listitemEvaluate(node, options, virtualNode) { const { parent } = virtualNode; @@ -7,8 +8,12 @@ export default function listitemEvaluate(node, options, virtualNode) { return undefined; } - const parentNodeName = parent.props.nodeName; - const parentRole = getExplicitRole(parent); + const listParent = getListParent(parent); + if (!listParent) { + return false; + } + + const parentRole = getExplicitRole(listParent); if (['presentation', 'none', 'list'].includes(parentRole)) { return true; @@ -20,5 +25,24 @@ export default function listitemEvaluate(node, options, virtualNode) { }); return false; } - return ['ul', 'ol', 'menu'].includes(parentNodeName); + return ['ul', 'ol', 'menu'].includes(listParent.props.nodeName); +} + +/** + * Walk up through custom element shadow hosts to find the semantic parent. + * Custom elements with shadow DOM that wrap
  • are transparent — the + * real parent is the element above the custom element host. + */ +function getListParent(vNode) { + let current = vNode; + while ( + current && + current.actualNode && + isShadowRoot(current.actualNode) && + !getExplicitRole(current) && + !['ul', 'ol', 'menu'].includes(current.props.nodeName) + ) { + current = current.parent; + } + return current || null; } diff --git a/lib/checks/lists/structured-dlitems-evaluate.js b/lib/checks/lists/structured-dlitems-evaluate.js index ad2d7f837d..e5f999256d 100644 --- a/lib/checks/lists/structured-dlitems-evaluate.js +++ b/lib/checks/lists/structured-dlitems-evaluate.js @@ -1,14 +1,18 @@ +import { getVisibleShadowChildren } from '../../commons/dom'; + function structuredDlitemsEvaluate(node, options, virtualNode) { const children = virtualNode.children; if (!children || !children.length) { return false; } + const resolved = resolveShadowChildren(children); + let hasDt = false, hasDd = false, nodeName; - for (let i = 0; i < children.length; i++) { - nodeName = children[i].props.nodeName.toUpperCase(); + for (let i = 0; i < resolved.length; i++) { + nodeName = resolved[i].props.nodeName.toUpperCase(); if (nodeName === 'DT') { hasDt = true; } @@ -23,4 +27,21 @@ function structuredDlitemsEvaluate(node, options, virtualNode) { return hasDt || hasDd; } +/** + * For each child, if it is a custom element shadow host, replace it with + * its visible shadow DOM element children (e.g.
    ). + */ +function resolveShadowChildren(children) { + const result = []; + for (const child of children) { + const shadowChildren = getVisibleShadowChildren(child); + if (shadowChildren) { + result.push(...shadowChildren); + } else { + result.push(child); + } + } + return result; +} + export default structuredDlitemsEvaluate; diff --git a/lib/commons/dom/get-visible-shadow-children.js b/lib/commons/dom/get-visible-shadow-children.js new file mode 100644 index 0000000000..48600683b7 --- /dev/null +++ b/lib/commons/dom/get-visible-shadow-children.js @@ -0,0 +1,21 @@ +import { isShadowRoot } from '../../core/utils'; +import isVisibleToScreenReaders from './is-visible-to-screenreader'; + +/** + * Get the visible element children from a shadow host's shadow DOM. + * Returns an array of visible vNode element children if the node is a + * shadow host, or null otherwise. + * @param {VirtualNode} vNode + * @returns {VirtualNode[]|null} + */ +export default function getVisibleShadowChildren(vNode) { + if (!vNode.actualNode || !isShadowRoot(vNode.actualNode) || !vNode.children) { + return null; + } + + const visibleElements = vNode.children.filter( + child => child.actualNode?.nodeType === 1 && isVisibleToScreenReaders(child) + ); + + return visibleElements.length > 0 ? visibleElements : null; +} diff --git a/lib/commons/dom/index.js b/lib/commons/dom/index.js index 3449238aa1..d409f81a7c 100644 --- a/lib/commons/dom/index.js +++ b/lib/commons/dom/index.js @@ -21,6 +21,7 @@ export { default as getTargetRects } from './get-target-rects'; export { default as getTargetSize } from './get-target-size'; export { default as getTextElementStack } from './get-text-element-stack'; export { default as getViewportSize } from './get-viewport-size'; +export { default as getVisibleShadowChildren } from './get-visible-shadow-children'; export { default as getVisibleChildTextRects } from './get-visible-child-text-rects'; export { default as hasContentVirtual } from './has-content-virtual'; export { default as hasContent } from './has-content'; diff --git a/test/checks/lists/listitem.js b/test/checks/lists/listitem.js index 24b6adeb52..f96540a962 100644 --- a/test/checks/lists/listitem.js +++ b/test/checks/lists/listitem.js @@ -115,4 +115,188 @@ describe('listitem', function () { assert.isFalse(result); } ); + + (shadowSupport.v1 ? describe : describe.skip)( + 'custom elements with shadow DOM', + function () { + it('should return true when
  • in custom element shadow DOM is inside a
      ', function () { + var item = document.createElement('my-list-item'); + item.attachShadow({ mode: 'open' }).innerHTML = + '
    • '; + item.textContent = 'Item 1'; + + var host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = + '
      '; + + fixtureSetup(host); + var target = item.shadowRoot.querySelector('li'); + var virtualTarget = axe.utils.getNodeFromTree(target); + var result = checkEvaluate.apply(checkContext, [ + target, + {}, + virtualTarget + ]); + assert.isTrue(result); + }); + + it('should return false when
    • in custom element shadow DOM is not inside a list', function () { + var item = document.createElement('my-list-item'); + item.attachShadow({ mode: 'open' }).innerHTML = + '
    • '; + item.textContent = 'Item 1'; + + var host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = + '
      '; + + fixtureSetup(host); + var target = item.shadowRoot.querySelector('li'); + var virtualTarget = axe.utils.getNodeFromTree(target); + var result = checkEvaluate.apply(checkContext, [ + target, + {}, + virtualTarget + ]); + assert.isFalse(result); + }); + + it('should return true when
    • in custom element shadow DOM is inside a role="list"', function () { + var item = document.createElement('my-list-item'); + item.attachShadow({ mode: 'open' }).innerHTML = + '
    • '; + item.textContent = 'Item 1'; + + var host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = + '
      '; + + fixtureSetup(host); + var target = item.shadowRoot.querySelector('li'); + var virtualTarget = axe.utils.getNodeFromTree(target); + var result = checkEvaluate.apply(checkContext, [ + target, + {}, + virtualTarget + ]); + assert.isTrue(result); + }); + + it('should return false when custom element has no shadow root', function () { + var item = document.createElement('my-list-item'); + item.innerHTML = '
    • Item 1
    • '; + + var host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = + '
      '; + + fixtureSetup(host); + var target = item.querySelector('#target'); + var virtualTarget = axe.utils.getNodeFromTree(target); + var result = checkEvaluate.apply(checkContext, [ + target, + {}, + virtualTarget + ]); + assert.isFalse(result); + }); + + it('should return true when
    • in custom element shadow DOM is inside a
        ', function () { + var item = document.createElement('my-list-item'); + item.attachShadow({ mode: 'open' }).innerHTML = + '
      1. '; + item.textContent = 'Item 1'; + + var host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + + fixtureSetup(host); + var target = item.shadowRoot.querySelector('li'); + var virtualTarget = axe.utils.getNodeFromTree(target); + var result = checkEvaluate.apply(checkContext, [ + target, + {}, + virtualTarget + ]); + assert.isTrue(result); + }); + + it('should return false when custom element parent has a non-list role', function () { + var item = document.createElement('my-list-item'); + item.attachShadow({ mode: 'open' }).innerHTML = + '
      2. '; + item.textContent = 'Item 1'; + + var host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + + fixtureSetup(host); + var target = item.shadowRoot.querySelector('li'); + var virtualTarget = axe.utils.getNodeFromTree(target); + var result = checkEvaluate.apply(checkContext, [ + target, + {}, + virtualTarget + ]); + assert.isFalse(result); + }); + + it('should stop walking at a custom element with an explicit role', function () { + var item = document.createElement('my-list-item'); + item.setAttribute('role', 'listitem'); + item.attachShadow({ mode: 'open' }).innerHTML = + '
      3. '; + item.textContent = 'Item 1'; + + var host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + + fixtureSetup(host); + var target = item.shadowRoot.querySelector('li'); + var virtualTarget = axe.utils.getNodeFromTree(target); + var result = checkEvaluate.apply(checkContext, [ + target, + {}, + virtualTarget + ]); + assert.isFalse(result); + }); + + it('should return true when
      4. is nested inside multiple custom elements', function () { + var item = document.createElement('my-list-item'); + item.attachShadow({ mode: 'open' }).innerHTML = + '
      5. '; + item.textContent = 'Item 1'; + + var wrapper = document.createElement('my-wrapper'); + wrapper.attachShadow({ mode: 'open' }).innerHTML = ''; + wrapper.appendChild(item); + + var host = document.createElement('div'); + host.appendChild(wrapper); + host.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + + fixtureSetup(host); + var target = item.shadowRoot.querySelector('li'); + var virtualTarget = axe.utils.getNodeFromTree(target); + var result = checkEvaluate.apply(checkContext, [ + target, + {}, + virtualTarget + ]); + assert.isTrue(result); + }); + } + ); }); diff --git a/test/checks/lists/only-dlitems.js b/test/checks/lists/only-dlitems.js index fd7af0dc06..3f70090083 100644 --- a/test/checks/lists/only-dlitems.js +++ b/test/checks/lists/only-dlitems.js @@ -223,5 +223,64 @@ describe('only-dlitems', () => { assert.isTrue(checkEvaluate.apply(checkContext, checkArgs)); assert.deepEqual(checkContext._data, { values: 'p' }); }); + + it('should return false when custom elements wrap
        and
        in shadow DOM', () => { + const term = document.createElement('my-term'); + term.attachShadow({ mode: 'open' }).innerHTML = '
        '; + term.textContent = 'Term'; + + const def = document.createElement('my-definition'); + def.attachShadow({ mode: 'open' }).innerHTML = '
        '; + def.textContent = 'Definition'; + + const host = document.createElement('div'); + host.appendChild(term); + host.appendChild(def); + host.attachShadow({ mode: 'open' }).innerHTML = '
        '; + + const checkArgs = checkSetup(host, 'dl'); + assert.isFalse(checkEvaluate.apply(checkContext, checkArgs)); + }); + + it('should return true when custom element has no shadow root', () => { + const item = document.createElement('my-term'); + item.innerHTML = '
        Term
        '; + + const host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = '
        '; + + const checkArgs = checkSetup(host, 'dl'); + assert.isTrue(checkEvaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { values: 'my-term' }); + }); + + it('should return true when custom element shadow DOM has invalid children', () => { + const item = document.createElement('my-term'); + item.attachShadow({ mode: 'open' }).innerHTML = '

        Not a dt or dd

        '; + + const host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = '
        '; + + const checkArgs = checkSetup(host, 'dl'); + assert.isTrue(checkEvaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { values: 'my-term' }); + }); + + it('should return true when custom element has aria-hidden shadow DOM
        ', () => { + const term = document.createElement('my-term'); + term.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + term.textContent = 'Term'; + + const host = document.createElement('div'); + host.appendChild(term); + host.attachShadow({ mode: 'open' }).innerHTML = '
        '; + + const checkArgs = checkSetup(host, 'dl'); + assert.isTrue(checkEvaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { values: 'my-term' }); + }); }); }); diff --git a/test/checks/lists/only-listitems.js b/test/checks/lists/only-listitems.js index 29e59c4ded..3f86371181 100644 --- a/test/checks/lists/only-listitems.js +++ b/test/checks/lists/only-listitems.js @@ -221,6 +221,67 @@ describe('only-listitems', () => { }); describe('shadow DOM', () => { + it('should return false when custom elements with shadow DOM
      6. are slotted into a list', () => { + // Simulate web components whose shadow DOM renders
      7. + const item1 = document.createElement('my-list-item'); + item1.attachShadow({ mode: 'open' }).innerHTML = '
      8. '; + item1.textContent = 'Item 1'; + + const item2 = document.createElement('my-list-item'); + item2.attachShadow({ mode: 'open' }).innerHTML = '
      9. '; + item2.textContent = 'Item 2'; + + // Use a div (in possibleShadowRoots) as the outer shadow host, + // matching the pattern of the other shadow DOM tests in this suite + const host = document.createElement('div'); + host.appendChild(item1); + host.appendChild(item2); + host.attachShadow({ mode: 'open' }).innerHTML = '
        '; + + const checkArgs = checkSetup(host, 'ul'); + assert.isFalse(checkEvaluate.apply(checkContext, checkArgs)); + }); + + it('should return true when custom elements have a shadow DOM
      10. ', () => { + const item1 = document.createElement('my-list-item'); + item1.attachShadow({ mode: 'open' }).innerHTML = + '
      11. '; + item1.textContent = 'Item 1'; + + const item2 = document.createElement('my-list-item'); + item2.attachShadow({ mode: 'open' }).innerHTML = + '
      12. '; + item2.textContent = 'Item 2'; + + const host = document.createElement('div'); + host.appendChild(item1); + host.appendChild(item2); + host.attachShadow({ mode: 'open' }).innerHTML = '
        '; + + const checkArgs = checkSetup(host, 'ul'); + assert.isTrue(checkEvaluate.apply(checkContext, checkArgs)); + }); + + it('should return true when custom elements have an aria-hidden shadow DOM
      13. ', () => { + const item1 = document.createElement('my-list-item'); + item1.attachShadow({ mode: 'open' }).innerHTML = + '
      14. '; + item1.textContent = 'Item 1'; + + const item2 = document.createElement('my-list-item'); + item2.attachShadow({ mode: 'open' }).innerHTML = + ''; + item2.textContent = 'Item 2'; + + const host = document.createElement('div'); + host.appendChild(item1); + host.appendChild(item2); + host.attachShadow({ mode: 'open' }).innerHTML = '
        '; + + const checkArgs = checkSetup(host, 'ul'); + assert.isTrue(checkEvaluate.apply(checkContext, checkArgs)); + }); + it('should return false in a shadow DOM pass', () => { const node = document.createElement('div'); node.innerHTML = '
      15. My list item
      16. '; @@ -243,5 +304,58 @@ describe('only-listitems', () => { values: 'p' }); }); + + it('should return true when a custom element has no shadow root', () => { + const item = document.createElement('my-list-item'); + item.innerHTML = '
      17. Item 1
      18. '; + + const host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = '
        '; + + const checkArgs = checkSetup(host, 'ul'); + assert.isTrue(checkEvaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { values: 'my-list-item' }); + }); + + it('should return false when a custom element shadow DOM has multiple valid children', () => { + const item = document.createElement('my-list-item'); + item.attachShadow({ mode: 'open' }).innerHTML = '
      19. A
      20. B
      21. '; + + const host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = '
        '; + + const checkArgs = checkSetup(host, 'ul'); + assert.isFalse(checkEvaluate.apply(checkContext, checkArgs)); + }); + + it('should return true when a custom element shadow DOM has invalid children', () => { + const item = document.createElement('my-list-item'); + item.attachShadow({ mode: 'open' }).innerHTML = + '
        Not a list item
        '; + + const host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = '
        '; + + const checkArgs = checkSetup(host, 'ul'); + assert.isTrue(checkEvaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { values: 'my-list-item' }); + }); + + it('should return true when a custom element shadow DOM has a valid first child but invalid subsequent children', () => { + const item = document.createElement('my-list-item'); + item.attachShadow({ mode: 'open' }).innerHTML = + '
      22. Valid
      23. Invalid
        '; + + const host = document.createElement('div'); + host.appendChild(item); + host.attachShadow({ mode: 'open' }).innerHTML = '
        '; + + const checkArgs = checkSetup(host, 'ul'); + assert.isTrue(checkEvaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._data, { values: 'my-list-item' }); + }); }); }); diff --git a/test/checks/lists/structured-dlitems.js b/test/checks/lists/structured-dlitems.js index 4060fb8dc7..a94f080caf 100644 --- a/test/checks/lists/structured-dlitems.js +++ b/test/checks/lists/structured-dlitems.js @@ -118,4 +118,102 @@ describe('structured-dlitems', function () { ); } ); + + (shadowSupport.v1 ? describe : describe.skip)( + 'custom elements with shadow DOM', + function () { + it('should return false when custom elements wrap
        and
        in correct order', function () { + var term = document.createElement('my-term'); + term.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + term.textContent = 'Term'; + + var def = document.createElement('my-definition'); + def.attachShadow({ mode: 'open' }).innerHTML = '
        '; + def.textContent = 'Definition'; + + var host = document.createElement('div'); + host.appendChild(term); + host.appendChild(def); + host.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + + var checkArgs = checkSetup(host, 'dl'); + assert.isFalse( + axe.testUtils + .getCheckEvaluate('structured-dlitems') + .apply(null, checkArgs) + ); + }); + + it('should return true when custom elements wrap
        and
        in wrong order', function () { + var def = document.createElement('my-definition'); + def.attachShadow({ mode: 'open' }).innerHTML = '
        '; + def.textContent = 'Definition'; + + var term = document.createElement('my-term'); + term.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + term.textContent = 'Term'; + + var host = document.createElement('div'); + host.appendChild(def); + host.appendChild(term); + host.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + + var checkArgs = checkSetup(host, 'dl'); + assert.isTrue( + axe.testUtils + .getCheckEvaluate('structured-dlitems') + .apply(null, checkArgs) + ); + }); + + it('should return false when custom element wraps
        followed by native
        ', function () { + var term = document.createElement('my-term'); + term.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + term.textContent = 'Term'; + + var dd = document.createElement('dd'); + dd.textContent = 'Definition'; + + var host = document.createElement('div'); + host.appendChild(term); + host.appendChild(dd); + host.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + + var checkArgs = checkSetup(host, 'dl'); + assert.isFalse( + axe.testUtils + .getCheckEvaluate('structured-dlitems') + .apply(null, checkArgs) + ); + }); + + it('should return false when native
        followed by custom element wrapping
        ', function () { + var dt = document.createElement('dt'); + dt.textContent = 'Term'; + + var def = document.createElement('my-definition'); + def.attachShadow({ mode: 'open' }).innerHTML = '
        '; + def.textContent = 'Definition'; + + var host = document.createElement('div'); + host.appendChild(dt); + host.appendChild(def); + host.attachShadow({ mode: 'open' }).innerHTML = + '
        '; + + var checkArgs = checkSetup(host, 'dl'); + assert.isFalse( + axe.testUtils + .getCheckEvaluate('structured-dlitems') + .apply(null, checkArgs) + ); + }); + } + ); });