Skip to content

Commit 03e1477

Browse files
feat(query-deep): Using a query selector that supports queries into the shadow dom of elements
1 parent b6b9b5b commit 03e1477

15 files changed

+141
-14
lines changed

jest.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ module.exports = {
1717
...watchPlugins,
1818
require.resolve('jest-watch-select-projects'),
1919
],
20+
transformIgnorePatterns: ['node_modules/(?!(query-selector-shadow-dom)/)'],
2021
projects: [
2122
require.resolve('./tests/jest.config.dom.js'),
2223
require.resolve('./tests/jest.config.node.js'),

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
"chalk": "^4.1.0",
4747
"dom-accessibility-api": "^0.5.9",
4848
"lz-string": "^1.4.4",
49-
"pretty-format": "^27.0.2"
49+
"pretty-format": "^27.0.2",
50+
"query-selector-shadow-dom": "^1.0.0"
5051
},
5152
"devDependencies": {
5253
"@testing-library/jest-dom": "^5.11.6",

src/__node_tests__/index.js

+61
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import {JSDOM} from 'jsdom'
22
import * as dtl from '../'
33

4+
beforeEach(() => {
5+
const dom = new JSDOM()
6+
global.document = dom.window.document
7+
global.window = dom.window
8+
global.Node = dom.window.Node
9+
})
10+
411
test('works without a global dom', async () => {
512
const container = new JSDOM(`
613
<html>
@@ -77,6 +84,60 @@ test('works without a browser context on a dom node (JSDOM Fragment)', () => {
7784
`)
7885
})
7986

87+
test('works with a custom configured element query for shadow dom elements', async () => {
88+
const window = new JSDOM(`
89+
<html>
90+
<body>
91+
<example-input></example-input>
92+
</body>
93+
</html>
94+
`).window
95+
const document = window.document
96+
const container = document.body
97+
98+
// Given I have defined a component with shadow dom
99+
window.customElements.define(
100+
'example-input',
101+
class extends window.HTMLElement {
102+
constructor() {
103+
super()
104+
const shadow = this.attachShadow({mode: 'open'})
105+
106+
const div = document.createElement('div')
107+
const label = document.createElement('label')
108+
label.setAttribute('for', 'invisible-from-outer-dom')
109+
label.innerHTML =
110+
'Visible in browser, invisible for traditional queries'
111+
const input = document.createElement('input')
112+
input.setAttribute('id', 'invisible-from-outer-dom')
113+
div.appendChild(label)
114+
div.appendChild(input)
115+
shadow.appendChild(div)
116+
}
117+
},
118+
)
119+
120+
// Then it is part of the document
121+
expect(
122+
dtl.queryByLabelText(
123+
container,
124+
/Visible in browser, invisible for traditional queries/i,
125+
),
126+
).toBeInTheDocument()
127+
128+
// And it returns the expected item
129+
expect(
130+
dtl.getByLabelText(
131+
container,
132+
/Visible in browser, invisible for traditional queries/i,
133+
),
134+
).toMatchInlineSnapshot(`
135+
<input
136+
id=invisible-from-outer-dom
137+
/>
138+
`)
139+
})
140+
80141
test('byRole works without a global DOM', () => {
81142
const {
82143
window: {

src/__tests__/ariaAttributes.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -131,15 +131,15 @@ test('`selected: true` matches `aria-selected="true"` on supported roles', () =>
131131

132132
expect(
133133
getAllByRole('columnheader', {selected: true}).map(({id}) => id),
134-
).toEqual(['selected-native-columnheader', 'selected-columnheader'])
134+
).toEqual(['selected-columnheader', 'selected-native-columnheader'])
135135

136136
expect(getAllByRole('gridcell', {selected: true}).map(({id}) => id)).toEqual([
137137
'selected-gridcell',
138138
])
139139

140140
expect(getAllByRole('option', {selected: true}).map(({id}) => id)).toEqual([
141-
'selected-native-option',
142141
'selected-listbox-option',
142+
'selected-native-option',
143143
])
144144

145145
expect(getAllByRole('rowheader', {selected: true}).map(({id}) => id)).toEqual(
@@ -217,8 +217,8 @@ test('`level` matches elements with `heading` role', () => {
217217
])
218218

219219
expect(getAllByRole('heading', {level: 2}).map(({id}) => id)).toEqual([
220-
'first-heading-two',
221220
'second-heading-two',
221+
'first-heading-two',
222222
])
223223

224224
expect(getAllByRole('heading', {level: 3}).map(({id}) => id)).toEqual([

src/label-helpers.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {querySelector, querySelectorAll} from './queries/all-utils'
12
import {TEXT_NODE} from './helpers'
23

34
const labelledNodeNames = [
@@ -43,7 +44,7 @@ function getRealLabels(element: Element) {
4344

4445
if (!isLabelable(element)) return []
4546

46-
const labels = element.ownerDocument.querySelectorAll('label')
47+
const labels = querySelectorAll(element.ownerDocument, 'label')
4748
return Array.from(labels).filter(label => label.control === element)
4849
}
4950

@@ -63,7 +64,8 @@ function getLabels(
6364
const labelsId = ariaLabelledBy ? ariaLabelledBy.split(' ') : []
6465
return labelsId.length
6566
? labelsId.map(labelId => {
66-
const labellingElement = container.querySelector<HTMLElement>(
67+
const labellingElement = querySelector<Element, HTMLElement>(
68+
container,
6769
`[id="${labelId}"]`,
6870
)
6971
return labellingElement

src/queries/display-value.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
MatcherOptions,
88
} from '../../types'
99
import {
10+
querySelectorAll,
1011
getNodeText,
1112
matches,
1213
fuzzyMatches,
@@ -23,7 +24,10 @@ const queryAllByDisplayValue: AllByBoundAttribute = (
2324
const matcher = exact ? matches : fuzzyMatches
2425
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
2526
return Array.from(
26-
container.querySelectorAll<HTMLElement>(`input,textarea,select`),
27+
querySelectorAll<HTMLElement, HTMLElement>(
28+
container,
29+
`input,textarea,select`,
30+
),
2731
).filter(node => {
2832
if (node.tagName === 'SELECT') {
2933
const selectedOptions = Array.from(

src/queries/label-text.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,16 @@ import {
1717
makeSingleQuery,
1818
wrapAllByQueryWithSuggestion,
1919
wrapSingleQueryWithSuggestion,
20+
querySelectorAll,
21+
querySelector,
2022
} from './all-utils'
2123

2224
function queryAllLabels(
2325
container: HTMLElement,
2426
): {textToMatch: string | null; node: HTMLElement}[] {
25-
return Array.from(container.querySelectorAll<HTMLElement>('label,input'))
27+
return Array.from(
28+
querySelectorAll<HTMLElement, HTMLElement>(container, 'label,input'),
29+
)
2630
.map(node => {
2731
return {node, textToMatch: getLabelContent(node)}
2832
})
@@ -56,7 +60,7 @@ const queryAllByLabelText: AllByText = (
5660
const matcher = exact ? matches : fuzzyMatches
5761
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
5862
const matchingLabelledElements = Array.from(
59-
container.querySelectorAll<HTMLElement>('*'),
63+
querySelectorAll<HTMLElement, HTMLElement>(container, '*'),
6064
)
6165
.filter(element => {
6266
return (
@@ -169,7 +173,10 @@ function getTagNameOfElementAssociatedWithLabelViaFor(
169173
return null
170174
}
171175

172-
const element = container.querySelector(`[id="${htmlFor}"]`)
176+
const element = querySelector<Element, HTMLElement>(
177+
container,
178+
`[id="${htmlFor}"]`,
179+
)
173180
return element ? element.tagName.toLowerCase() : null
174181
}
175182

src/queries/role.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
getConfig,
2121
makeNormalizer,
2222
matches,
23+
querySelectorAll,
2324
} from './all-utils'
2425

2526
function queryAllByRole(
@@ -100,7 +101,8 @@ function queryAllByRole(
100101
}
101102

102103
return Array.from(
103-
container.querySelectorAll(
104+
querySelectorAll(
105+
container,
104106
// Only query elements that can be matched by the following filters
105107
makeRoleSelector(role, exact, normalizer ? matchNormalizer : undefined),
106108
),

src/queries/text.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {checkContainerType} from '../helpers'
33
import {DEFAULT_IGNORE_TAGS} from '../shared'
44
import {AllByText, GetErrorFunction} from '../../types'
55
import {
6+
querySelectorAll,
67
fuzzyMatches,
78
matches,
89
makeNormalizer,
@@ -32,7 +33,9 @@ const queryAllByText: AllByText = (
3233
return (
3334
[
3435
...baseArray,
35-
...Array.from(container.querySelectorAll<HTMLElement>(selector)),
36+
...Array.from(
37+
querySelectorAll<HTMLElement, HTMLElement>(container, selector),
38+
),
3639
]
3740
// TODO: `matches` according lib.dom.d.ts can get only `string` but according our code it can handle also boolean :)
3841
.filter(node => !ignore || !node.matches(ignore as string))

src/queries/title.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
MatcherOptions,
88
} from '../../types'
99
import {
10+
querySelectorAll,
1011
fuzzyMatches,
1112
matches,
1213
makeNormalizer,
@@ -27,7 +28,10 @@ const queryAllByTitle: AllByBoundAttribute = (
2728
const matcher = exact ? matches : fuzzyMatches
2829
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
2930
return Array.from(
30-
container.querySelectorAll<HTMLElement>('[title], svg > title'),
31+
querySelectorAll<HTMLElement, HTMLElement>(
32+
container,
33+
'[title], svg > title',
34+
),
3135
).filter(
3236
node =>
3337
matcher(node.getAttribute('title'), node, text, matchNormalizer) ||

src/query-helpers.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type {
22
GetErrorFunction,
33
Matcher,
44
MatcherOptions,
5+
QueryAllElements,
6+
QueryElement,
57
QueryMethod,
68
Variant,
79
waitForOptions as WaitForOptions,
@@ -11,6 +13,16 @@ import {getSuggestedQuery} from './suggestions'
1113
import {fuzzyMatches, matches, makeNormalizer} from './matches'
1214
import {waitFor} from './wait-for'
1315
import {getConfig} from './config'
16+
import * as querier from 'query-selector-shadow-dom'
17+
18+
export const querySelector: QueryElement = <T extends Element>(
19+
element: T,
20+
selector: string,
21+
) => querier.querySelectorDeep(selector, element)
22+
export const querySelectorAll: QueryAllElements = <T extends Element>(
23+
element: T,
24+
selector: string,
25+
) => querier.querySelectorAllDeep(selector, element)
1426

1527
function getElementError(message: string | null, container: HTMLElement) {
1628
return getConfig().getElementError(message, container)
@@ -35,7 +47,7 @@ function queryAllByAttribute(
3547
const matcher = exact ? matches : fuzzyMatches
3648
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
3749
return Array.from(
38-
container.querySelectorAll<HTMLElement>(`[${attribute}]`),
50+
querySelectorAll<HTMLElement, HTMLElement>(container, `[${attribute}]`),
3951
).filter(node =>
4052
matcher(node.getAttribute(attribute), node, text, matchNormalizer),
4153
)

tests/jest.config.dom.js

+1
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ module.exports = {
1010
'/__tests__/',
1111
'/__node_tests__/',
1212
],
13+
transformIgnorePatterns: ['node_modules/(?!(query-selector-shadow-dom)/)'],
1314
testEnvironment: 'jest-environment-jsdom',
1415
}

tests/jest.config.node.js

+1
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ module.exports = {
1111
'/__tests__/',
1212
'/__node_tests__/',
1313
],
14+
transformIgnorePatterns: ['node_modules/(?!(query-selector-shadow-dom)/)'],
1415
testMatch: ['**/__node_tests__/**.js'],
1516
}

types/query-helpers.d.ts

+24
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,27 @@ export function buildQueries<Arguments extends any[]>(
7272
getMultipleError: GetErrorFunction<Arguments>,
7373
getMissingError: GetErrorFunction<Arguments>,
7474
): BuiltQueryMethods<Arguments>
75+
76+
export type QueryElement = {
77+
<T, K extends keyof HTMLElementTagNameMap>(container: T, selectors: K):
78+
| HTMLElementTagNameMap[K]
79+
| null
80+
<T, K extends keyof SVGElementTagNameMap>(container: T, selectors: K):
81+
| SVGElementTagNameMap[K]
82+
| null
83+
<T, E extends Element = Element>(container: T, selectors: string): E | null
84+
}
85+
export type QueryAllElements = {
86+
<T, K extends keyof HTMLElementTagNameMap>(
87+
container: T,
88+
selectors: K,
89+
): NodeListOf<HTMLElementTagNameMap[K]>
90+
<T, K extends keyof SVGElementTagNameMap>(
91+
container: T,
92+
selectors: K,
93+
): NodeListOf<SVGElementTagNameMap[K]>
94+
<T, E extends Element = Element>(
95+
container: T,
96+
selectors: string,
97+
): NodeListOf<E>
98+
}

types/query-selector-shadow-dom.d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
declare module 'query-selector-shadow-dom' {
2+
export const querySelectorAllDeep: QueryElement
3+
export const querySelectorDeep: QueryAllElements
4+
}

0 commit comments

Comments
 (0)