Skip to content

Commit 53e0d18

Browse files
authored
fix(ByLabelText): get by label concat values (#681)
Closes #545
1 parent 657a767 commit 53e0d18

File tree

2 files changed

+188
-76
lines changed

2 files changed

+188
-76
lines changed

src/__tests__/element-queries.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ test('can get form controls by label text', () => {
168168
<label id="fifth-label-two">5th two</label>
169169
<input aria-labelledby="fifth-label-one fifth-label-two" id="fifth-id" />
170170
</div>
171+
<div>
172+
<input id="sixth-label-one" value="6th one"/>
173+
<input id="sixth-label-two" value="6th two"/>
174+
<label id="sixth-label-three">6th three</label>
175+
<input aria-labelledby="sixth-label-one sixth-label-two sixth-label-three" id="sixth-id" />
176+
</div>
177+
<div>
178+
<span id="seventh-label-one">7th one</span>
179+
<input aria-labelledby="seventh-label-one" id="seventh-id" />
180+
</div>
171181
</div>
172182
`)
173183
expect(getByLabelText('1st').id).toBe('first-id')
@@ -176,6 +186,11 @@ test('can get form controls by label text', () => {
176186
expect(getByLabelText('4th').id).toBe('fourth.id')
177187
expect(getByLabelText('5th one').id).toBe('fifth-id')
178188
expect(getByLabelText('5th two').id).toBe('fifth-id')
189+
expect(getByLabelText('6th one').id).toBe('sixth-id')
190+
expect(getByLabelText('6th two').id).toBe('sixth-id')
191+
expect(getByLabelText('6th one 6th two').id).toBe('sixth-id')
192+
expect(getByLabelText('6th one 6th two 6th three').id).toBe('sixth-id')
193+
expect(getByLabelText('7th one').id).toBe('seventh-id')
179194
})
180195

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

350+
test('label with no form control and fuzzy matcher', () => {
351+
const {getByLabelText, queryByLabelText} = render(
352+
`<label>All alone label</label>`,
353+
)
354+
expect(queryByLabelText('alone', {exact: false})).toBeNull()
355+
expect(() => getByLabelText('alone', {exact: false}))
356+
.toThrowErrorMatchingInlineSnapshot(`
357+
"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.
358+
359+
<div>
360+
<label>
361+
All alone label
362+
</label>
363+
</div>"
364+
`)
365+
})
366+
367+
test('label with children with no form control', () => {
368+
const {getByLabelText, queryByLabelText} = render(`
369+
<label>
370+
All alone but with children
371+
<textarea>Hello</textarea>
372+
<select><option value="0">zero</option></select>
373+
</label>`)
374+
expect(queryByLabelText(/alone/, {selector: 'input'})).toBeNull()
375+
expect(() => getByLabelText(/alone/, {selector: 'input'}))
376+
.toThrowErrorMatchingInlineSnapshot(`
377+
"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.
378+
379+
<div>
380+
381+
382+
<label>
383+
384+
All alone but with children
385+
386+
<textarea>
387+
Hello
388+
</textarea>
389+
390+
391+
<select>
392+
<option
393+
value="0"
394+
>
395+
zero
396+
</option>
397+
</select>
398+
399+
400+
</label>
401+
</div>"
402+
`)
403+
})
404+
335405
test('totally empty label', () => {
336406
const {getByLabelText, queryByLabelText} = render(`<label />`)
337407
expect(queryByLabelText('')).toBeNull()
@@ -947,3 +1017,26 @@ test('can get a select with options', () => {
9471017
`)
9481018
getByLabelText('Label')
9491019
})
1020+
1021+
test('can get an element with aria-labelledby when label has a child', () => {
1022+
const {getByLabelText} = render(`
1023+
<div>
1024+
<label id='label-with-textarea'>
1025+
First Label
1026+
<textarea>Value</textarea>
1027+
</label>
1028+
<input aria-labelledby='label-with-textarea' id='1st-input'/>
1029+
<label id='label-with-select'>
1030+
Second Label
1031+
<select><option value="1">one</option></select>
1032+
</label>
1033+
<input aria-labelledby='label-with-select' id='2nd-input'/>
1034+
</div>
1035+
`)
1036+
expect(getByLabelText('First Label', {selector: 'input'}).id).toBe(
1037+
'1st-input',
1038+
)
1039+
expect(getByLabelText('Second Label', {selector: 'input'}).id).toBe(
1040+
'2nd-input',
1041+
)
1042+
})

src/queries/label-text.js

Lines changed: 95 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,29 @@ import {
1010
wrapAllByQueryWithSuggestion,
1111
wrapSingleQueryWithSuggestion,
1212
} from './all-utils'
13-
import {queryAllByText} from './text'
13+
14+
function queryAllLabels(container) {
15+
return Array.from(container.querySelectorAll('label,input'))
16+
.map(node => {
17+
let textToMatch =
18+
node.tagName.toLowerCase() === 'label'
19+
? node.textContent
20+
: node.value || null
21+
// The children of a textarea are part of `textContent` as well. We
22+
// need to remove them from the string so we can match it afterwards.
23+
Array.from(node.querySelectorAll('textarea')).forEach(textarea => {
24+
textToMatch = textToMatch.replace(textarea.value, '')
25+
})
26+
27+
// The children of a select are also part of `textContent`, so we
28+
// need also to remove their text.
29+
Array.from(node.querySelectorAll('select')).forEach(select => {
30+
textToMatch = textToMatch.replace(select.textContent, '')
31+
})
32+
return {node, textToMatch}
33+
})
34+
.filter(({textToMatch}) => textToMatch !== null)
35+
}
1436

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

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

31-
// The children of a select are also part of `textContent`, so we
32-
// need also to remove their text.
33-
Array.from(label.querySelectorAll('select')).forEach(select => {
34-
textToMatch = textToMatch.replace(select.textContent, '')
35-
})
47+
return textToMatchByLabels
48+
.filter(({node, textToMatch}) =>
49+
matcher(textToMatch, node, text, matchNormalizer),
50+
)
51+
.map(({node}) => node)
52+
}
3653

37-
return matcher(textToMatch, label, text, matchNormalizer)
54+
function getLabelContent(label) {
55+
let labelContent = label.getAttribute('value') || label.textContent
56+
Array.from(label.querySelectorAll('textarea')).forEach(textarea => {
57+
labelContent = labelContent.replace(textarea.value, '')
3858
})
59+
Array.from(label.querySelectorAll('select')).forEach(select => {
60+
labelContent = labelContent.replace(select.textContent, '')
61+
})
62+
return labelContent
3963
}
4064

4165
function queryAllByLabelText(
@@ -45,74 +69,69 @@ function queryAllByLabelText(
4569
) {
4670
checkContainerType(container)
4771

72+
const matcher = exact ? matches : fuzzyMatches
4873
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
49-
const labels = queryAllLabelsByText(container, text, {
50-
exact,
51-
normalizer: matchNormalizer,
52-
})
53-
const labelledElements = labels
54-
.reduce((matchedElements, label) => {
55-
const elementsForLabel = []
56-
if (label.control) {
57-
elementsForLabel.push(label.control)
58-
}
59-
/* istanbul ignore if */
60-
if (label.getAttribute('for')) {
61-
// we're using this notation because with the # selector we would have to escape special characters e.g. user.name
62-
// see https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector#Escaping_special_characters
63-
// <label for="someId">text</label><input id="someId" />
64-
65-
// .control support has landed in jsdom (https://github.com/jsdom/jsdom/issues/2175)
66-
elementsForLabel.push(
67-
container.querySelector(`[id="${label.getAttribute('for')}"]`),
68-
)
69-
}
70-
if (label.getAttribute('id')) {
71-
// <label id="someId">text</label><input aria-labelledby="someId" />
72-
Array.from(
73-
container.querySelectorAll(
74-
`[aria-labelledby~="${label.getAttribute('id')}"]`,
75-
),
76-
).forEach(element => elementsForLabel.push(element))
77-
}
78-
if (label.childNodes.length) {
79-
// <label>text: <input /></label>
80-
const formControlSelector =
81-
'button, input, meter, output, progress, select, textarea'
82-
const labelledFormControl = Array.from(
83-
label.querySelectorAll(formControlSelector),
84-
).filter(element => element.matches(selector))[0]
85-
if (labelledFormControl) elementsForLabel.push(labelledFormControl)
74+
const matchingLabelledElements = Array.from(container.querySelectorAll('*'))
75+
.filter(
76+
element => element.labels || element.hasAttribute('aria-labelledby'),
77+
)
78+
.reduce((labelledElements, labelledElement) => {
79+
const labelsId = labelledElement.getAttribute('aria-labelledby')
80+
? labelledElement.getAttribute('aria-labelledby').split(' ')
81+
: []
82+
const labelsValue = labelsId.length
83+
? labelsId.map(labelId => {
84+
const labellingElement = container.querySelector(`[id=${labelId}]`)
85+
return getLabelContent(labellingElement)
86+
})
87+
: Array.from(labelledElement.labels).map(label => {
88+
const textToMatch = getLabelContent(label)
89+
const formControlSelector =
90+
'button, input, meter, output, progress, select, textarea'
91+
const labelledFormControl = Array.from(
92+
label.querySelectorAll(formControlSelector),
93+
).filter(element => element.matches(selector))[0]
94+
if (labelledFormControl) {
95+
if (
96+
matcher(textToMatch, labelledFormControl, text, matchNormalizer)
97+
)
98+
labelledElements.push(labelledFormControl)
99+
}
100+
return textToMatch
101+
})
102+
if (
103+
matcher(labelsValue.join(' '), labelledElement, text, matchNormalizer)
104+
)
105+
labelledElements.push(labelledElement)
106+
if (labelsValue.length > 1) {
107+
labelsValue.forEach((labelValue, index) => {
108+
if (matcher(labelValue, labelledElement, text, matchNormalizer))
109+
labelledElements.push(labelledElement)
110+
111+
const labelsFiltered = [...labelsValue]
112+
labelsFiltered.splice(index, 1)
113+
114+
if (labelsFiltered.length > 1) {
115+
if (
116+
matcher(
117+
labelsFiltered.join(' '),
118+
labelledElement,
119+
text,
120+
matchNormalizer,
121+
)
122+
)
123+
labelledElements.push(labelledElement)
124+
}
125+
})
86126
}
87-
return matchedElements.concat(elementsForLabel)
127+
128+
return labelledElements
88129
}, [])
89-
.filter(element => element !== null)
90130
.concat(queryAllByAttribute('aria-label', container, text, {exact}))
91131

92-
const possibleAriaLabelElements = queryAllByText(container, text, {
93-
exact,
94-
normalizer: matchNormalizer,
95-
})
96-
97-
const ariaLabelledElements = possibleAriaLabelElements.reduce(
98-
(allLabelledElements, nextLabelElement) => {
99-
const labelId = nextLabelElement.getAttribute('id')
100-
101-
if (!labelId) return allLabelledElements
102-
103-
// ARIA labels can label multiple elements
104-
const labelledNodes = Array.from(
105-
container.querySelectorAll(`[aria-labelledby~="${labelId}"]`),
106-
)
107-
108-
return allLabelledElements.concat(labelledNodes)
109-
},
110-
[],
132+
return Array.from(new Set(matchingLabelledElements)).filter(element =>
133+
element.matches(selector),
111134
)
112-
113-
return Array.from(
114-
new Set([...labelledElements, ...ariaLabelledElements]),
115-
).filter(element => element.matches(selector))
116135
}
117136

118137
// the getAll* query would normally look like this:

0 commit comments

Comments
 (0)