Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(queryconfig): Allows configuring a different element query mechanism #1054

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
196 changes: 196 additions & 0 deletions src/__node_tests__/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import {JSDOM} from 'jsdom'
import {configure} from '../config'
import * as dtl from '../'

beforeEach(() => {
// reset back to original config
configure({
queryAllElements: (element, query) => element.querySelectorAll(query),
})
})

test('works without a global dom', async () => {
const container = new JSDOM(`
<html>
Expand Down Expand Up @@ -77,6 +85,194 @@ 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

// create custom element as system under test
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)
}
},
)

// Given the default configuration is used

// When querying for the label
// Then it is not in the document
expect(
dtl.queryByLabelText(
container,
/Visible in browser, invisible for traditional queries/i,
),
).not.toBeInTheDocument()

// Given I have a naive query that allows searching shadow dom
const queryMeAndChildrenAndShadow = (element, query) => [
...element.querySelectorAll(`:scope > ${query}`),
...[...element.children].reduce(
(result, child) => [
...result,
...queryMeAndChildrenAndShadow(child, query),
],
[],
),
...(element.shadowRoot?.querySelectorAll(query) ?? []),
]

// When I configure the testing tools to use it
configure({
queryAllElements: queryMeAndChildrenAndShadow,
})

// 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('fails with a cross-boundary request for an element query for shadow dom elements with an appropriate error message', async () => {
const window = new JSDOM(`
<html>
<body>
<example-input></example-input>
</body>
</html>
`).window
const document = window.document
const container = document.body

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('nested-example-input')
label.appendChild(input)
div.appendChild(label)
shadow.appendChild(div)
}
},
)

window.customElements.define(
'nested-example-input',
class extends window.HTMLElement {
static formAssociated = true
constructor() {
super()
const shadow = this.attachShadow({mode: 'open'})

const form = document.createElement('form')
const input = document.createElement('input')
input.setAttribute('id', 'invisible-from-outer-dom')
input.setAttribute('type', 'text')
form.appendChild(input)
shadow.appendChild(form)
}
},
)

// Given I have a naive query that allows searching shadow dom
const queryDeep = (element, query) =>
[
...element.querySelectorAll(`:scope > ${query}`),
...(element.shadowRoot ? queryDeep(element.shadowRoot, query) : []),
...[...element.children].reduce(
(result, child) => [...result, ...queryDeep(child, query)],
[],
),
].filter(item => !!item)
const queryAll = (element, query) => [
...element.querySelectorAll(query),
...queryDeep(element, query),
]
const queryMeAndChildrenAndShadow = (_, query) =>
queryAll(document, query).find(element => !!element)
const queryMeAndChildrenAndShadowAll = (_, query) => [
...queryAll(document, query),
]

// When I configure the testing tools to use it
configure({
queryElement: queryMeAndChildrenAndShadow,
queryAllElements: queryMeAndChildrenAndShadowAll,
})

// Then it should throw an error informing me that the input is currently non-labelable
// While the error message is not correct, the scenario is currently mostly relevant when
// extending the query mechanism with shadow dom, so it's not something that the framework
// can foresee at the moment. https://github.com/WICG/webcomponents/issues/917 will hopefully
// resolve this issue
expect(() =>
dtl.getAllByLabelText(
container,
/Visible in browser, invisible for traditional queries/i,
),
).toThrowErrorMatchingInlineSnapshot(`
Found a label with the text of: /Visible in browser, invisible for traditional queries/i, however the element associated with this label (<input />) is non-labellable [https://html.spec.whatwg.org/multipage/forms.html#category-label]. If you really need to label a <input />, you can use aria-label or aria-labelledby instead.

Ignored nodes: comments, <script />, <style />
<body>


Object {
Symbol(SameObject caches): Object {
"children": HTMLCollection [],
},
}




</body>
`)
})

test('byRole works without a global DOM', () => {
const {
window: {
Expand Down
14 changes: 13 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import {Config, ConfigFn} from '../types/config'
import {Config, ConfigFn, QueryAllElements, QueryElement} from '../types/config'
import {prettyDOM} from './pretty-dom'

type Callback<T> = () => T

const queryElement: QueryElement = <T extends Element>(
element: T,
selector: string,
) => element.querySelector(selector)
const queryElementAll: QueryAllElements = <T extends Element>(
element: T,
selector: string,
) => element.querySelectorAll(selector)

interface InternalConfig extends Config {
_disableExpensiveErrorDiagnostics: boolean
}
Expand Down Expand Up @@ -48,6 +58,8 @@ let config: InternalConfig = {
},
_disableExpensiveErrorDiagnostics: false,
computedStyleSupportsPseudoElements: false,
queryElement,
queryAllElements: queryElementAll,
}

export function runWithExpensiveErrorDiagnosticsDisabled<T>(
Expand Down
12 changes: 9 additions & 3 deletions src/label-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {TEXT_NODE} from './helpers'

import {getConfig} from './config'

const labelledNodeNames = [
'button',
'meter',
Expand Down Expand Up @@ -43,7 +45,7 @@ function getRealLabels(element: Element) {

if (!isLabelable(element)) return []

const labels = element.ownerDocument.querySelectorAll('label')
const labels = getConfig().queryAllElements(element.ownerDocument, 'label')
return Array.from(labels).filter(label => label.control === element)
}

Expand All @@ -63,7 +65,8 @@ function getLabels(
const labelsId = ariaLabelledBy ? ariaLabelledBy.split(' ') : []
return labelsId.length
? labelsId.map(labelId => {
const labellingElement = container.querySelector<HTMLElement>(
const labellingElement = getConfig().queryElement(
container,
`[id="${labelId}"]`,
)
return labellingElement
Expand All @@ -75,7 +78,10 @@ function getLabels(
const formControlSelector =
'button, input, meter, output, progress, select, textarea'
const labelledFormControl = Array.from(
label.querySelectorAll<HTMLElement>(formControlSelector),
getConfig().queryAllElements<Element, HTMLElement>(
label,
formControlSelector,
),
).filter(formControlElement => formControlElement.matches(selector))[0]
return {content: textToMatch, formControl: labelledFormControl}
})
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 @@ -12,6 +12,7 @@ import {
fuzzyMatches,
makeNormalizer,
buildQueries,
getConfig,
} from './all-utils'

const queryAllByDisplayValue: AllByBoundAttribute = (
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`),
getConfig().queryAllElements<HTMLElement, HTMLElement>(
container,
`input,textarea,select`,
),
).filter(node => {
if (node.tagName === 'SELECT') {
const selectedOptions = Array.from(
Expand Down
13 changes: 9 additions & 4 deletions src/queries/label-text.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {getConfig} from '../config'
import {checkContainerType} from '../helpers'
import {getLabels, getRealLabels, getLabelContent} from '../label-helpers'
import {
Expand All @@ -17,12 +16,18 @@ import {
makeSingleQuery,
wrapAllByQueryWithSuggestion,
wrapSingleQueryWithSuggestion,
getConfig,
} from './all-utils'

function queryAllLabels(
container: HTMLElement,
): {textToMatch: string | null; node: HTMLElement}[] {
return Array.from(container.querySelectorAll<HTMLElement>('label,input'))
return Array.from(
getConfig().queryAllElements<HTMLElement, HTMLElement>(
container,
'label,input',
),
)
.map(node => {
return {node, textToMatch: getLabelContent(node)}
})
Expand Down Expand Up @@ -56,7 +61,7 @@ const queryAllByLabelText: AllByText = (
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
const matchingLabelledElements = Array.from(
container.querySelectorAll<HTMLElement>('*'),
getConfig().queryAllElements<HTMLElement, HTMLElement>(container, '*'),
)
.filter(element => {
return (
Expand Down Expand Up @@ -169,7 +174,7 @@ function getTagNameOfElementAssociatedWithLabelViaFor(
return null
}

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

Expand Down
3 changes: 2 additions & 1 deletion src/queries/role.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ function queryAllByRole(
}

return Array.from(
container.querySelectorAll(
getConfig().queryAllElements(
container,
// Only query elements that can be matched by the following filters
makeRoleSelector(role, exact, normalizer ? matchNormalizer : undefined),
),
Expand Down
9 changes: 7 additions & 2 deletions src/queries/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import {
import {
fuzzyMatches,
matches,
getConfig,
makeNormalizer,
getNodeText,
buildQueries,
getConfig,
} from './all-utils'

const queryAllByText: AllByText = (
Expand All @@ -37,7 +37,12 @@ const queryAllByText: AllByText = (
return (
[
...baseArray,
...Array.from(container.querySelectorAll<HTMLElement>(selector)),
...Array.from(
getConfig().queryAllElements<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 @@ -9,6 +9,7 @@ import {
import {
fuzzyMatches,
matches,
getConfig,
makeNormalizer,
getNodeText,
buildQueries,
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'),
getConfig().queryAllElements<HTMLElement, HTMLElement>(
container,
'[title], svg > title',
),
).filter(
node =>
matcher(node.getAttribute('title'), node, text, matchNormalizer) ||
Expand Down