From 03e1477fcfb51633077d360d3911dc0bd8885fcb Mon Sep 17 00:00:00 2001 From: Matthias Kainer Date: Mon, 25 Oct 2021 16:38:05 +0200 Subject: [PATCH] feat(query-deep): Using a query selector that supports queries into the shadow dom of elements --- jest.config.js | 1 + package.json | 3 +- src/__node_tests__/index.js | 61 ++++++++++++++++++++++++++++ src/__tests__/ariaAttributes.js | 6 +-- src/label-helpers.ts | 6 ++- src/queries/display-value.ts | 6 ++- src/queries/label-text.ts | 13 ++++-- src/queries/role.js | 4 +- src/queries/text.ts | 5 ++- src/queries/title.ts | 6 ++- src/query-helpers.ts | 14 ++++++- tests/jest.config.dom.js | 1 + tests/jest.config.node.js | 1 + types/query-helpers.d.ts | 24 +++++++++++ types/query-selector-shadow-dom.d.ts | 4 ++ 15 files changed, 141 insertions(+), 14 deletions(-) create mode 100644 types/query-selector-shadow-dom.d.ts diff --git a/jest.config.js b/jest.config.js index 7a7a2b50..d4ea4309 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,6 +17,7 @@ module.exports = { ...watchPlugins, require.resolve('jest-watch-select-projects'), ], + transformIgnorePatterns: ['node_modules/(?!(query-selector-shadow-dom)/)'], projects: [ require.resolve('./tests/jest.config.dom.js'), require.resolve('./tests/jest.config.node.js'), diff --git a/package.json b/package.json index 02d084af..47dd6524 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.4.4", - "pretty-format": "^27.0.2" + "pretty-format": "^27.0.2", + "query-selector-shadow-dom": "^1.0.0" }, "devDependencies": { "@testing-library/jest-dom": "^5.11.6", diff --git a/src/__node_tests__/index.js b/src/__node_tests__/index.js index 6c663360..bdbf91de 100644 --- a/src/__node_tests__/index.js +++ b/src/__node_tests__/index.js @@ -1,6 +1,13 @@ import {JSDOM} from 'jsdom' import * as dtl from '../' +beforeEach(() => { + const dom = new JSDOM() + global.document = dom.window.document + global.window = dom.window + global.Node = dom.window.Node +}) + test('works without a global dom', async () => { const container = new JSDOM(` @@ -77,6 +84,60 @@ test('works without a browser context on a dom node (JSDOM Fragment)', () => { `) }) +test('works with a custom configured element query for shadow dom elements', async () => { + const window = new JSDOM(` + + + + + + `).window + const document = window.document + const container = document.body + + // Given I have defined a component with shadow dom + window.customElements.define( + 'example-input', + class extends window.HTMLElement { + constructor() { + super() + const shadow = this.attachShadow({mode: 'open'}) + + const div = document.createElement('div') + const label = document.createElement('label') + label.setAttribute('for', 'invisible-from-outer-dom') + label.innerHTML = + 'Visible in browser, invisible for traditional queries' + const input = document.createElement('input') + input.setAttribute('id', 'invisible-from-outer-dom') + div.appendChild(label) + div.appendChild(input) + shadow.appendChild(div) + } + }, + ) + + // Then it is part of the document + expect( + dtl.queryByLabelText( + container, + /Visible in browser, invisible for traditional queries/i, + ), + ).toBeInTheDocument() + + // And it returns the expected item + expect( + dtl.getByLabelText( + container, + /Visible in browser, invisible for traditional queries/i, + ), + ).toMatchInlineSnapshot(` + + `) +}) + test('byRole works without a global DOM', () => { const { window: { diff --git a/src/__tests__/ariaAttributes.js b/src/__tests__/ariaAttributes.js index 1ceb210d..eab7a936 100644 --- a/src/__tests__/ariaAttributes.js +++ b/src/__tests__/ariaAttributes.js @@ -131,15 +131,15 @@ test('`selected: true` matches `aria-selected="true"` on supported roles', () => expect( getAllByRole('columnheader', {selected: true}).map(({id}) => id), - ).toEqual(['selected-native-columnheader', 'selected-columnheader']) + ).toEqual(['selected-columnheader', 'selected-native-columnheader']) expect(getAllByRole('gridcell', {selected: true}).map(({id}) => id)).toEqual([ 'selected-gridcell', ]) expect(getAllByRole('option', {selected: true}).map(({id}) => id)).toEqual([ - 'selected-native-option', 'selected-listbox-option', + 'selected-native-option', ]) expect(getAllByRole('rowheader', {selected: true}).map(({id}) => id)).toEqual( @@ -217,8 +217,8 @@ test('`level` matches elements with `heading` role', () => { ]) expect(getAllByRole('heading', {level: 2}).map(({id}) => id)).toEqual([ - 'first-heading-two', 'second-heading-two', + 'first-heading-two', ]) expect(getAllByRole('heading', {level: 3}).map(({id}) => id)).toEqual([ diff --git a/src/label-helpers.ts b/src/label-helpers.ts index 4e1a3fed..ad6f223c 100644 --- a/src/label-helpers.ts +++ b/src/label-helpers.ts @@ -1,3 +1,4 @@ +import {querySelector, querySelectorAll} from './queries/all-utils' import {TEXT_NODE} from './helpers' const labelledNodeNames = [ @@ -43,7 +44,7 @@ function getRealLabels(element: Element) { if (!isLabelable(element)) return [] - const labels = element.ownerDocument.querySelectorAll('label') + const labels = querySelectorAll(element.ownerDocument, 'label') return Array.from(labels).filter(label => label.control === element) } @@ -63,7 +64,8 @@ function getLabels( const labelsId = ariaLabelledBy ? ariaLabelledBy.split(' ') : [] return labelsId.length ? labelsId.map(labelId => { - const labellingElement = container.querySelector( + const labellingElement = querySelector( + container, `[id="${labelId}"]`, ) return labellingElement diff --git a/src/queries/display-value.ts b/src/queries/display-value.ts index 7177a84a..1e373b2d 100644 --- a/src/queries/display-value.ts +++ b/src/queries/display-value.ts @@ -7,6 +7,7 @@ import { MatcherOptions, } from '../../types' import { + querySelectorAll, getNodeText, matches, fuzzyMatches, @@ -23,7 +24,10 @@ const queryAllByDisplayValue: AllByBoundAttribute = ( const matcher = exact ? matches : fuzzyMatches const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) return Array.from( - container.querySelectorAll(`input,textarea,select`), + querySelectorAll( + container, + `input,textarea,select`, + ), ).filter(node => { if (node.tagName === 'SELECT') { const selectedOptions = Array.from( diff --git a/src/queries/label-text.ts b/src/queries/label-text.ts index 39e766d5..a56f67f1 100644 --- a/src/queries/label-text.ts +++ b/src/queries/label-text.ts @@ -17,12 +17,16 @@ import { makeSingleQuery, wrapAllByQueryWithSuggestion, wrapSingleQueryWithSuggestion, + querySelectorAll, + querySelector, } from './all-utils' function queryAllLabels( container: HTMLElement, ): {textToMatch: string | null; node: HTMLElement}[] { - return Array.from(container.querySelectorAll('label,input')) + return Array.from( + querySelectorAll(container, 'label,input'), + ) .map(node => { return {node, textToMatch: getLabelContent(node)} }) @@ -56,7 +60,7 @@ const queryAllByLabelText: AllByText = ( const matcher = exact ? matches : fuzzyMatches const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) const matchingLabelledElements = Array.from( - container.querySelectorAll('*'), + querySelectorAll(container, '*'), ) .filter(element => { return ( @@ -169,7 +173,10 @@ function getTagNameOfElementAssociatedWithLabelViaFor( return null } - const element = container.querySelector(`[id="${htmlFor}"]`) + const element = querySelector( + container, + `[id="${htmlFor}"]`, + ) return element ? element.tagName.toLowerCase() : null } diff --git a/src/queries/role.js b/src/queries/role.js index 826edd43..69c8bca7 100644 --- a/src/queries/role.js +++ b/src/queries/role.js @@ -20,6 +20,7 @@ import { getConfig, makeNormalizer, matches, + querySelectorAll, } from './all-utils' function queryAllByRole( @@ -100,7 +101,8 @@ function queryAllByRole( } return Array.from( - container.querySelectorAll( + querySelectorAll( + container, // Only query elements that can be matched by the following filters makeRoleSelector(role, exact, normalizer ? matchNormalizer : undefined), ), diff --git a/src/queries/text.ts b/src/queries/text.ts index 0e6ac3f7..4d3e2644 100644 --- a/src/queries/text.ts +++ b/src/queries/text.ts @@ -3,6 +3,7 @@ import {checkContainerType} from '../helpers' import {DEFAULT_IGNORE_TAGS} from '../shared' import {AllByText, GetErrorFunction} from '../../types' import { + querySelectorAll, fuzzyMatches, matches, makeNormalizer, @@ -32,7 +33,9 @@ const queryAllByText: AllByText = ( return ( [ ...baseArray, - ...Array.from(container.querySelectorAll(selector)), + ...Array.from( + querySelectorAll(container, selector), + ), ] // TODO: `matches` according lib.dom.d.ts can get only `string` but according our code it can handle also boolean :) .filter(node => !ignore || !node.matches(ignore as string)) diff --git a/src/queries/title.ts b/src/queries/title.ts index 7366855f..1dd315f2 100644 --- a/src/queries/title.ts +++ b/src/queries/title.ts @@ -7,6 +7,7 @@ import { MatcherOptions, } from '../../types' import { + querySelectorAll, fuzzyMatches, matches, makeNormalizer, @@ -27,7 +28,10 @@ const queryAllByTitle: AllByBoundAttribute = ( const matcher = exact ? matches : fuzzyMatches const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) return Array.from( - container.querySelectorAll('[title], svg > title'), + querySelectorAll( + container, + '[title], svg > title', + ), ).filter( node => matcher(node.getAttribute('title'), node, text, matchNormalizer) || diff --git a/src/query-helpers.ts b/src/query-helpers.ts index 155210e1..5288ba9b 100644 --- a/src/query-helpers.ts +++ b/src/query-helpers.ts @@ -2,6 +2,8 @@ import type { GetErrorFunction, Matcher, MatcherOptions, + QueryAllElements, + QueryElement, QueryMethod, Variant, waitForOptions as WaitForOptions, @@ -11,6 +13,16 @@ import {getSuggestedQuery} from './suggestions' import {fuzzyMatches, matches, makeNormalizer} from './matches' import {waitFor} from './wait-for' import {getConfig} from './config' +import * as querier from 'query-selector-shadow-dom' + +export const querySelector: QueryElement = ( + element: T, + selector: string, +) => querier.querySelectorDeep(selector, element) +export const querySelectorAll: QueryAllElements = ( + element: T, + selector: string, +) => querier.querySelectorAllDeep(selector, element) function getElementError(message: string | null, container: HTMLElement) { return getConfig().getElementError(message, container) @@ -35,7 +47,7 @@ function queryAllByAttribute( const matcher = exact ? matches : fuzzyMatches const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) return Array.from( - container.querySelectorAll(`[${attribute}]`), + querySelectorAll(container, `[${attribute}]`), ).filter(node => matcher(node.getAttribute(attribute), node, text, matchNormalizer), ) diff --git a/tests/jest.config.dom.js b/tests/jest.config.dom.js index 72b3b24b..ea330af4 100644 --- a/tests/jest.config.dom.js +++ b/tests/jest.config.dom.js @@ -10,5 +10,6 @@ module.exports = { '/__tests__/', '/__node_tests__/', ], + transformIgnorePatterns: ['node_modules/(?!(query-selector-shadow-dom)/)'], testEnvironment: 'jest-environment-jsdom', } diff --git a/tests/jest.config.node.js b/tests/jest.config.node.js index bf37b60b..f72e6bfc 100644 --- a/tests/jest.config.node.js +++ b/tests/jest.config.node.js @@ -11,5 +11,6 @@ module.exports = { '/__tests__/', '/__node_tests__/', ], + transformIgnorePatterns: ['node_modules/(?!(query-selector-shadow-dom)/)'], testMatch: ['**/__node_tests__/**.js'], } diff --git a/types/query-helpers.d.ts b/types/query-helpers.d.ts index 7b9904b1..8a9b150b 100644 --- a/types/query-helpers.d.ts +++ b/types/query-helpers.d.ts @@ -72,3 +72,27 @@ export function buildQueries( getMultipleError: GetErrorFunction, getMissingError: GetErrorFunction, ): BuiltQueryMethods + +export type QueryElement = { + (container: T, selectors: K): + | HTMLElementTagNameMap[K] + | null + (container: T, selectors: K): + | SVGElementTagNameMap[K] + | null + (container: T, selectors: string): E | null +} +export type QueryAllElements = { + ( + container: T, + selectors: K, + ): NodeListOf + ( + container: T, + selectors: K, + ): NodeListOf + ( + container: T, + selectors: string, + ): NodeListOf +} diff --git a/types/query-selector-shadow-dom.d.ts b/types/query-selector-shadow-dom.d.ts new file mode 100644 index 00000000..f92a50df --- /dev/null +++ b/types/query-selector-shadow-dom.d.ts @@ -0,0 +1,4 @@ +declare module 'query-selector-shadow-dom' { + export const querySelectorAllDeep: QueryElement + export const querySelectorDeep: QueryAllElements +}