Skip to content

get by label concat values #681

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/__tests__/element-queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,16 @@ test('can get form controls by label text', () => {
<label id="fifth-label-two">5th two</label>
<input aria-labelledby="fifth-label-one fifth-label-two" id="fifth-id" />
</div>
<div>
<input id="sixth-label-one" value="6th one"/>
<input id="sixth-label-two" value="6th two"/>
<label id="sixth-label-three">6th three</label>
<input aria-labelledby="sixth-label-one sixth-label-two sixth-label-three" id="sixth-id" />
</div>
<div>
<span id="seventh-label-one">7th one</span>
<input aria-labelledby="seventh-label-one" id="seventh-id" />
</div>
</div>
`)
expect(getByLabelText('1st').id).toBe('first-id')
Expand All @@ -176,6 +186,11 @@ test('can get form controls by label text', () => {
expect(getByLabelText('4th').id).toBe('fourth.id')
expect(getByLabelText('5th one').id).toBe('fifth-id')
expect(getByLabelText('5th two').id).toBe('fifth-id')
expect(getByLabelText('6th one').id).toBe('sixth-id')
expect(getByLabelText('6th two').id).toBe('sixth-id')
expect(getByLabelText('6th one 6th two').id).toBe('sixth-id')
expect(getByLabelText('6th one 6th two 6th three').id).toBe('sixth-id')
expect(getByLabelText('7th one').id).toBe('seventh-id')
})

test('can get elements labelled with aria-labelledby attribute', () => {
Expand Down Expand Up @@ -332,6 +347,61 @@ test('label with no form control', () => {
`)
})

test('label with no form control and fuzzy matcher', () => {
const {getByLabelText, queryByLabelText} = render(
`<label>All alone label</label>`,
)
expect(queryByLabelText('alone', {exact: false})).toBeNull()
expect(() => getByLabelText('alone', {exact: false}))
.toThrowErrorMatchingInlineSnapshot(`
"Found a label with the text of: alone, however no form control was found associated to that label. Make sure you're using the "for" attribute or "aria-labelledby" attribute correctly.

<div>
<label>
All alone label
</label>
</div>"
`)
})

test('label with children with no form control', () => {
const {getByLabelText, queryByLabelText} = render(`
<label>
All alone but with children
<textarea>Hello</textarea>
<select><option value="0">zero</option></select>
</label>`)
Comment on lines +369 to +373
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should find textarea. And it does so when using label.control

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I am not understanding, you are saying that the right test to be done here (at line 374) was that the labeled element is textarea instead of that no input element labeled was found?

getByLabelText(/alone/) would have found the textarea element, I added the selector to make it return null.

Please tell me if I am missing something.

expect(queryByLabelText(/alone/, {selector: 'input'})).toBeNull()
expect(() => getByLabelText(/alone/, {selector: 'input'}))
.toThrowErrorMatchingInlineSnapshot(`
"Found a label with the text of: /alone/, however no form control was found associated to that label. Make sure you're using the "for" attribute or "aria-labelledby" attribute correctly.

<div>


<label>

All alone but with children

<textarea>
Hello
</textarea>


<select>
<option
value="0"
>
zero
</option>
</select>


</label>
</div>"
`)
})

test('totally empty label', () => {
const {getByLabelText, queryByLabelText} = render(`<label />`)
expect(queryByLabelText('')).toBeNull()
Expand Down Expand Up @@ -947,3 +1017,26 @@ test('can get a select with options', () => {
`)
getByLabelText('Label')
})

test('can get an element with aria-labelledby when label has a child', () => {
const {getByLabelText} = render(`
<div>
<label id='label-with-textarea'>
First Label
<textarea>Value</textarea>
</label>
<input aria-labelledby='label-with-textarea' id='1st-input'/>
<label id='label-with-select'>
Second Label
<select><option value="1">one</option></select>
</label>
<input aria-labelledby='label-with-select' id='2nd-input'/>
</div>
`)
expect(getByLabelText('First Label', {selector: 'input'}).id).toBe(
'1st-input',
)
expect(getByLabelText('Second Label', {selector: 'input'}).id).toBe(
'2nd-input',
)
})
171 changes: 95 additions & 76 deletions src/queries/label-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,29 @@ import {
wrapAllByQueryWithSuggestion,
wrapSingleQueryWithSuggestion,
} from './all-utils'
import {queryAllByText} from './text'

function queryAllLabels(container) {
return Array.from(container.querySelectorAll('label,input'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It occurs to me that an element can be labeled by anything, not just label and input. So while this may be solving for the input case, it doesn't solve for everything else 🤔

That said, I'm not sure how to handle "everything else." It seems to me that rather than searching for all possible elements that can label anything, we should be searching for everything that's labeled first, determine the contents of their label, and then return the elements whose label matches the query.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what it's worth, I think that searching for labelled items and then for their labels is more feasible than querying for all elements that could be labels. Like you said above!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you'd like to tack a whack at that, I think it would solve a lot of problems.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For sure I would like to do it!

.map(node => {
let textToMatch =
node.tagName.toLowerCase() === 'label'
? node.textContent
: node.value || null
// The children of a textarea are part of `textContent` as well. We
// need to remove them from the string so we can match it afterwards.
Array.from(node.querySelectorAll('textarea')).forEach(textarea => {
textToMatch = textToMatch.replace(textarea.value, '')
})

// The children of a select are also part of `textContent`, so we
// need also to remove their text.
Array.from(node.querySelectorAll('select')).forEach(select => {
textToMatch = textToMatch.replace(select.textContent, '')
})
return {node, textToMatch}
})
.filter(({textToMatch}) => textToMatch !== null)
}

function queryAllLabelsByText(
container,
Expand All @@ -19,23 +41,25 @@ function queryAllLabelsByText(
) {
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(container.querySelectorAll('label')).filter(label => {
let textToMatch = label.textContent

// The children of a textarea are part of `textContent` as well. We
// need to remove them from the string so we can match it afterwards.
Array.from(label.querySelectorAll('textarea')).forEach(textarea => {
textToMatch = textToMatch.replace(textarea.value, '')
})
const textToMatchByLabels = queryAllLabels(container)

// The children of a select are also part of `textContent`, so we
// need also to remove their text.
Array.from(label.querySelectorAll('select')).forEach(select => {
textToMatch = textToMatch.replace(select.textContent, '')
})
return textToMatchByLabels
.filter(({node, textToMatch}) =>
matcher(textToMatch, node, text, matchNormalizer),
)
.map(({node}) => node)
}

return matcher(textToMatch, label, text, matchNormalizer)
function getLabelContent(label) {
let labelContent = label.getAttribute('value') || label.textContent
Array.from(label.querySelectorAll('textarea')).forEach(textarea => {
labelContent = labelContent.replace(textarea.value, '')
})
Array.from(label.querySelectorAll('select')).forEach(select => {
labelContent = labelContent.replace(select.textContent, '')
})
return labelContent
}

function queryAllByLabelText(
Expand All @@ -45,74 +69,69 @@ function queryAllByLabelText(
) {
checkContainerType(container)

const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
const labels = queryAllLabelsByText(container, text, {
exact,
normalizer: matchNormalizer,
})
const labelledElements = labels
.reduce((matchedElements, label) => {
const elementsForLabel = []
if (label.control) {
elementsForLabel.push(label.control)
}
/* istanbul ignore if */
if (label.getAttribute('for')) {
// we're using this notation because with the # selector we would have to escape special characters e.g. user.name
// see https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector#Escaping_special_characters
// <label for="someId">text</label><input id="someId" />

// .control support has landed in jsdom (https://github.com/jsdom/jsdom/issues/2175)
elementsForLabel.push(
container.querySelector(`[id="${label.getAttribute('for')}"]`),
)
}
if (label.getAttribute('id')) {
// <label id="someId">text</label><input aria-labelledby="someId" />
Array.from(
container.querySelectorAll(
`[aria-labelledby~="${label.getAttribute('id')}"]`,
),
).forEach(element => elementsForLabel.push(element))
}
if (label.childNodes.length) {
// <label>text: <input /></label>
const formControlSelector =
'button, input, meter, output, progress, select, textarea'
const labelledFormControl = Array.from(
label.querySelectorAll(formControlSelector),
).filter(element => element.matches(selector))[0]
if (labelledFormControl) elementsForLabel.push(labelledFormControl)
const matchingLabelledElements = Array.from(container.querySelectorAll('*'))
.filter(
element => element.labels || element.hasAttribute('aria-labelledby'),
)
.reduce((labelledElements, labelledElement) => {
const labelsId = labelledElement.getAttribute('aria-labelledby')
? labelledElement.getAttribute('aria-labelledby').split(' ')
: []
const labelsValue = labelsId.length
? labelsId.map(labelId => {
Copy link
Member

@kentcdodds kentcdodds Jul 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if an element had both a .labels property and the aria-labelledby? I don't know if that's valid or not. Is it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aria-labelledby has precedence: https://www.w3.org/TR/accname-1.1/#step2B

2B is for aria-labelledby, 2D for

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, I think this PR is good to go! Anyone have other thoughts?

const labellingElement = container.querySelector(`[id=${labelId}]`)
return getLabelContent(labellingElement)
})
: Array.from(labelledElement.labels).map(label => {
const textToMatch = getLabelContent(label)
const formControlSelector =
'button, input, meter, output, progress, select, textarea'
const labelledFormControl = Array.from(
label.querySelectorAll(formControlSelector),
).filter(element => element.matches(selector))[0]
if (labelledFormControl) {
if (
matcher(textToMatch, labelledFormControl, text, matchNormalizer)
)
labelledElements.push(labelledFormControl)
}
return textToMatch
})
if (
matcher(labelsValue.join(' '), labelledElement, text, matchNormalizer)
)
labelledElements.push(labelledElement)
if (labelsValue.length > 1) {
labelsValue.forEach((labelValue, index) => {
if (matcher(labelValue, labelledElement, text, matchNormalizer))
labelledElements.push(labelledElement)

const labelsFiltered = [...labelsValue]
labelsFiltered.splice(index, 1)

if (labelsFiltered.length > 1) {
if (
matcher(
labelsFiltered.join(' '),
labelledElement,
text,
matchNormalizer,
)
)
labelledElements.push(labelledElement)
}
})
}
return matchedElements.concat(elementsForLabel)

return labelledElements
}, [])
.filter(element => element !== null)
.concat(queryAllByAttribute('aria-label', container, text, {exact}))

const possibleAriaLabelElements = queryAllByText(container, text, {
exact,
normalizer: matchNormalizer,
})

const ariaLabelledElements = possibleAriaLabelElements.reduce(
(allLabelledElements, nextLabelElement) => {
const labelId = nextLabelElement.getAttribute('id')

if (!labelId) return allLabelledElements

// ARIA labels can label multiple elements
const labelledNodes = Array.from(
container.querySelectorAll(`[aria-labelledby~="${labelId}"]`),
)

return allLabelledElements.concat(labelledNodes)
},
[],
return Array.from(new Set(matchingLabelledElements)).filter(element =>
element.matches(selector),
)

return Array.from(
new Set([...labelledElements, ...ariaLabelledElements]),
).filter(element => element.matches(selector))
}

// the getAll* query would normally look like this:
Expand Down