diff --git a/src/__tests__/suggestions.js b/src/__tests__/suggestions.js index f0c7fb66..00b40dfb 100644 --- a/src/__tests__/suggestions.js +++ b/src/__tests__/suggestions.js @@ -578,3 +578,44 @@ test('should suggest hidden option if element is not in the accessibilty tree', ] `) }) + +test('should find label text using the aria-labelledby', () => { + const {container} = renderIntoDocument(` +
+
+ + + + +
+
+ `) + + expect( + getSuggestedQuery( + container.querySelector('[id="sixth-id"]'), + 'get', + 'labelText', + ), + ).toMatchInlineSnapshot( + { + queryArgs: [/6th one 6th two 6th three/i], + queryMethod: 'getByLabelText', + queryName: 'LabelText', + variant: 'get', + warning: '', + }, + ` + Object { + "queryArgs": Array [ + Object {}, + ], + "queryMethod": "getByLabelText", + "queryName": "LabelText", + "toString": [Function], + "variant": "get", + "warning": "", + } + `, + ) +}) diff --git a/src/label-helpers.js b/src/label-helpers.js new file mode 100644 index 00000000..52c5dc99 --- /dev/null +++ b/src/label-helpers.js @@ -0,0 +1,75 @@ +import {TEXT_NODE} from './helpers' + +const labelledNodeNames = [ + 'button', + 'meter', + 'output', + 'progress', + 'select', + 'textarea', + 'input', +] + +function getTextContent(node) { + if (labelledNodeNames.includes(node.nodeName.toLowerCase())) { + return '' + } + + if (node.nodeType === TEXT_NODE) return node.textContent + + return Array.from(node.childNodes) + .map(childNode => getTextContent(childNode)) + .join('') +} + +function getLabelContent(node) { + let textContent + if (node.tagName.toLowerCase() === 'label') { + textContent = getTextContent(node) + } else { + textContent = node.value || node.textContent + } + return textContent +} + +// Based on https://github.com/eps1lon/dom-accessibility-api/pull/352 +function getRealLabels(element) { + if (element.labels !== undefined) return element.labels + + if (!isLabelable(element)) return [] + + const labels = element.ownerDocument.querySelectorAll('label') + return Array.from(labels).filter(label => label.control === element) +} + +function isLabelable(element) { + return ( + element.tagName.match(/BUTTON|METER|OUTPUT|PROGRESS|SELECT|TEXTAREA/) || + (element.tagName === 'INPUT' && element.getAttribute('type') !== 'hidden') + ) +} + +function getLabels(container, element, {selector = '*'} = {}) { + const labelsId = element.getAttribute('aria-labelledby') + ? element.getAttribute('aria-labelledby').split(' ') + : [] + return labelsId.length + ? labelsId.map(labelId => { + const labellingElement = container.querySelector(`[id="${labelId}"]`) + return labellingElement + ? {content: getLabelContent(labellingElement), formControl: null} + : {content: '', formControl: null} + }) + : Array.from(getRealLabels(element)).map(label => { + const textToMatch = getLabelContent(label) + const formControlSelector = + 'button, input, meter, output, progress, select, textarea' + const labelledFormControl = Array.from( + label.querySelectorAll(formControlSelector), + ).filter(formControlElement => formControlElement.matches(selector))[0] + + return {content: textToMatch, formControl: labelledFormControl} + }) +} + +export {getLabels, getRealLabels, getLabelContent} diff --git a/src/queries/label-text.js b/src/queries/label-text.js index fc86d583..b4a45cc6 100644 --- a/src/queries/label-text.js +++ b/src/queries/label-text.js @@ -1,5 +1,6 @@ import {getConfig} from '../config' -import {checkContainerType, TEXT_NODE} from '../helpers' +import {checkContainerType} from '../helpers' +import {getLabels, getRealLabels, getLabelContent} from '../label-helpers' import { fuzzyMatches, matches, @@ -11,16 +12,6 @@ import { wrapSingleQueryWithSuggestion, } from './all-utils' -const labelledNodeNames = [ - 'button', - 'meter', - 'output', - 'progress', - 'select', - 'textarea', - 'input', -] - function queryAllLabels(container) { return Array.from(container.querySelectorAll('label,input')) .map(node => { @@ -46,28 +37,6 @@ function queryAllLabelsByText( .map(({node}) => node) } -function getTextContent(node) { - if (labelledNodeNames.includes(node.nodeName.toLowerCase())) { - return '' - } - - if (node.nodeType === TEXT_NODE) return node.textContent - - return Array.from(node.childNodes) - .map(childNode => getTextContent(childNode)) - .join('') -} - -function getLabelContent(node) { - let textContent - if (node.tagName.toLowerCase() === 'label') { - textContent = getTextContent(node) - } else { - textContent = node.value || node.textContent - } - return textContent -} - function queryAllByLabelText( container, text, @@ -79,34 +48,21 @@ function queryAllByLabelText( const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) const matchingLabelledElements = Array.from(container.querySelectorAll('*')) .filter(element => { - return getLabels(element) || element.hasAttribute('aria-labelledby') + return ( + getRealLabels(element).length || element.hasAttribute('aria-labelledby') + ) }) .reduce((labelledElements, labelledElement) => { - const labelsId = labelledElement.getAttribute('aria-labelledby') - ? labelledElement.getAttribute('aria-labelledby').split(' ') - : [] - let labelsValue = labelsId.length - ? labelsId.map(labelId => { - const labellingElement = container.querySelector( - `[id="${labelId}"]`, - ) - return labellingElement ? getLabelContent(labellingElement) : '' - }) - : Array.from(getLabels(labelledElement)).map(label => { - const textToMatch = getLabelContent(label) - const formControlSelector = labelledNodeNames.join(',') - 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 - }) - labelsValue = labelsValue.filter(Boolean) + const labelList = getLabels(container, labelledElement, {selector}) + labelList + .filter(label => Boolean(label.formControl)) + .forEach(label => { + if (matcher(label.content, label.formControl, text, matchNormalizer)) + labelledElements.push(label.formControl) + }) + const labelsValue = labelList + .filter(label => Boolean(label.content)) + .map(label => label.content) if ( matcher(labelsValue.join(' '), labelledElement, text, matchNormalizer) ) @@ -232,6 +188,7 @@ const queryAllByLabelTextWithSuggestions = wrapAllByQueryWithSuggestion( queryAllByLabelText.name, 'queryAll', ) + export { queryAllByLabelTextWithSuggestions as queryAllByLabelText, queryByLabelText, @@ -240,20 +197,3 @@ export { findAllByLabelText, findByLabelText, } - -// Based on https://github.com/eps1lon/dom-accessibility-api/pull/352 -function getLabels(element) { - if (element.labels !== undefined) return element.labels - - if (!isLabelable(element)) return null - - const labels = element.ownerDocument.querySelectorAll('label') - return Array.from(labels).filter(label => label.control === element) -} - -function isLabelable(element) { - return ( - element.tagName.match(/BUTTON|METER|OUTPUT|PROGRESS|SELECT|TEXTAREA/) || - (element.tagName === 'INPUT' && element.getAttribute('type') !== 'hidden') - ) -} diff --git a/src/suggestions.js b/src/suggestions.js index e3fef0e7..0f1bef6a 100644 --- a/src/suggestions.js +++ b/src/suggestions.js @@ -3,33 +3,10 @@ import {getDefaultNormalizer} from './matches' import {getNodeText} from './get-node-text' import {DEFAULT_IGNORE_TAGS, getConfig} from './config' import {getImplicitAriaRoles, isInaccessible} from './role-helpers' +import {getLabels} from './label-helpers' const normalize = getDefaultNormalizer() -function getLabelTextFor(element) { - let label = - element.labels && - Array.from(element.labels).find(el => Boolean(normalize(el.textContent))) - - // non form elements that are using aria-labelledby won't be included in `element.labels` - if (!label) { - const ariaLabelledBy = element.getAttribute('aria-labelledby') - if (ariaLabelledBy) { - // this is only a temporary fix. The problem is that at the moment @testing-library/dom - // not support label concatenation - // see https://github.com/testing-library/dom-testing-library/issues/545 - const firstId = ariaLabelledBy.split(' ')[0] - // 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 = document.querySelector(`[id="${firstId}"]`) - } - } - - if (label) { - return label.textContent - } - return undefined -} function escapeRegExp(string) { return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string } @@ -113,7 +90,9 @@ export function getSuggestedQuery(element, variant = 'get', method) { }) } - const labelText = getLabelTextFor(element) + const labelText = getLabels(document, element) + .map(label => label.content) + .join(' ') if (canSuggest('LabelText', method, labelText)) { return makeSuggestion('LabelText', element, labelText, {variant}) }