Skip to content

Commit

Permalink
feat(query-deep): Using a query selector that supports queries into t…
Browse files Browse the repository at this point in the history
…he shadow dom of elements
  • Loading branch information
MatthiasKainer committed Oct 25, 2021
1 parent b6b9b5b commit 03e1477
Show file tree
Hide file tree
Showing 15 changed files with 141 additions and 14 deletions.
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions src/__node_tests__/index.js
Original file line number Diff line number Diff line change
@@ -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(`
<html>
Expand Down Expand Up @@ -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(`
<html>
<body>
<example-input></example-input>
</body>
</html>
`).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(`
<input
id=invisible-from-outer-dom
/>
`)
})

test('byRole works without a global DOM', () => {
const {
window: {
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/ariaAttributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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([
Expand Down
6 changes: 4 additions & 2 deletions src/label-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {querySelector, querySelectorAll} from './queries/all-utils'
import {TEXT_NODE} from './helpers'

const labelledNodeNames = [
Expand Down Expand Up @@ -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)
}

Expand All @@ -63,7 +64,8 @@ function getLabels(
const labelsId = ariaLabelledBy ? ariaLabelledBy.split(' ') : []
return labelsId.length
? labelsId.map(labelId => {
const labellingElement = container.querySelector<HTMLElement>(
const labellingElement = querySelector<Element, HTMLElement>(
container,
`[id="${labelId}"]`,
)
return labellingElement
Expand Down
6 changes: 5 additions & 1 deletion src/queries/display-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MatcherOptions,
} from '../../types'
import {
querySelectorAll,
getNodeText,
matches,
fuzzyMatches,
Expand All @@ -23,7 +24,10 @@ const queryAllByDisplayValue: AllByBoundAttribute = (
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(
container.querySelectorAll<HTMLElement>(`input,textarea,select`),
querySelectorAll<HTMLElement, HTMLElement>(
container,
`input,textarea,select`,
),
).filter(node => {
if (node.tagName === 'SELECT') {
const selectedOptions = Array.from(
Expand Down
13 changes: 10 additions & 3 deletions src/queries/label-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('label,input'))
return Array.from(
querySelectorAll<HTMLElement, HTMLElement>(container, 'label,input'),
)
.map(node => {
return {node, textToMatch: getLabelContent(node)}
})
Expand Down Expand Up @@ -56,7 +60,7 @@ const queryAllByLabelText: AllByText = (
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
const matchingLabelledElements = Array.from(
container.querySelectorAll<HTMLElement>('*'),
querySelectorAll<HTMLElement, HTMLElement>(container, '*'),
)
.filter(element => {
return (
Expand Down Expand Up @@ -169,7 +173,10 @@ function getTagNameOfElementAssociatedWithLabelViaFor(
return null
}

const element = container.querySelector(`[id="${htmlFor}"]`)
const element = querySelector<Element, HTMLElement>(
container,
`[id="${htmlFor}"]`,
)
return element ? element.tagName.toLowerCase() : null
}

Expand Down
4 changes: 3 additions & 1 deletion src/queries/role.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getConfig,
makeNormalizer,
matches,
querySelectorAll,
} from './all-utils'

function queryAllByRole(
Expand Down Expand Up @@ -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),
),
Expand Down
5 changes: 4 additions & 1 deletion src/queries/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {checkContainerType} from '../helpers'
import {DEFAULT_IGNORE_TAGS} from '../shared'
import {AllByText, GetErrorFunction} from '../../types'
import {
querySelectorAll,
fuzzyMatches,
matches,
makeNormalizer,
Expand Down Expand Up @@ -32,7 +33,9 @@ const queryAllByText: AllByText = (
return (
[
...baseArray,
...Array.from(container.querySelectorAll<HTMLElement>(selector)),
...Array.from(
querySelectorAll<HTMLElement, HTMLElement>(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))
Expand Down
6 changes: 5 additions & 1 deletion src/queries/title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MatcherOptions,
} from '../../types'
import {
querySelectorAll,
fuzzyMatches,
matches,
makeNormalizer,
Expand All @@ -27,7 +28,10 @@ const queryAllByTitle: AllByBoundAttribute = (
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(
container.querySelectorAll<HTMLElement>('[title], svg > title'),
querySelectorAll<HTMLElement, HTMLElement>(
container,
'[title], svg > title',
),
).filter(
node =>
matcher(node.getAttribute('title'), node, text, matchNormalizer) ||
Expand Down
14 changes: 13 additions & 1 deletion src/query-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type {
GetErrorFunction,
Matcher,
MatcherOptions,
QueryAllElements,
QueryElement,
QueryMethod,
Variant,
waitForOptions as WaitForOptions,
Expand All @@ -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 = <T extends Element>(
element: T,
selector: string,
) => querier.querySelectorDeep(selector, element)
export const querySelectorAll: QueryAllElements = <T extends Element>(
element: T,
selector: string,
) => querier.querySelectorAllDeep(selector, element)

function getElementError(message: string | null, container: HTMLElement) {
return getConfig().getElementError(message, container)
Expand All @@ -35,7 +47,7 @@ function queryAllByAttribute(
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(
container.querySelectorAll<HTMLElement>(`[${attribute}]`),
querySelectorAll<HTMLElement, HTMLElement>(container, `[${attribute}]`),
).filter(node =>
matcher(node.getAttribute(attribute), node, text, matchNormalizer),
)
Expand Down
1 change: 1 addition & 0 deletions tests/jest.config.dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ module.exports = {
'/__tests__/',
'/__node_tests__/',
],
transformIgnorePatterns: ['node_modules/(?!(query-selector-shadow-dom)/)'],
testEnvironment: 'jest-environment-jsdom',
}
1 change: 1 addition & 0 deletions tests/jest.config.node.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ module.exports = {
'/__tests__/',
'/__node_tests__/',
],
transformIgnorePatterns: ['node_modules/(?!(query-selector-shadow-dom)/)'],
testMatch: ['**/__node_tests__/**.js'],
}
24 changes: 24 additions & 0 deletions types/query-helpers.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,27 @@ export function buildQueries<Arguments extends any[]>(
getMultipleError: GetErrorFunction<Arguments>,
getMissingError: GetErrorFunction<Arguments>,
): BuiltQueryMethods<Arguments>

export type QueryElement = {
<T, K extends keyof HTMLElementTagNameMap>(container: T, selectors: K):
| HTMLElementTagNameMap[K]
| null
<T, K extends keyof SVGElementTagNameMap>(container: T, selectors: K):
| SVGElementTagNameMap[K]
| null
<T, E extends Element = Element>(container: T, selectors: string): E | null
}
export type QueryAllElements = {
<T, K extends keyof HTMLElementTagNameMap>(
container: T,
selectors: K,
): NodeListOf<HTMLElementTagNameMap[K]>
<T, K extends keyof SVGElementTagNameMap>(
container: T,
selectors: K,
): NodeListOf<SVGElementTagNameMap[K]>
<T, E extends Element = Element>(
container: T,
selectors: string,
): NodeListOf<E>
}
4 changes: 4 additions & 0 deletions types/query-selector-shadow-dom.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module 'query-selector-shadow-dom' {
export const querySelectorAllDeep: QueryElement
export const querySelectorDeep: QueryAllElements
}

0 comments on commit 03e1477

Please sign in to comment.