Skip to content

Commit

Permalink
fix(ByLabelText): get by label concat values (#681)
Browse files Browse the repository at this point in the history
Closes #545
  • Loading branch information
delca85 committed Jul 20, 2020
1 parent 657a767 commit 53e0d18
Show file tree
Hide file tree
Showing 2 changed files with 188 additions and 76 deletions.
93 changes: 93 additions & 0 deletions src/__tests__/element-queries.js
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>`)
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
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'))
.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 => {
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

0 comments on commit 53e0d18

Please sign in to comment.