diff --git a/lib/commons/text/native-text-methods.js b/lib/commons/text/native-text-methods.js index dcc11a70df..5578e4ed4a 100644 --- a/lib/commons/text/native-text-methods.js +++ b/lib/commons/text/native-text-methods.js @@ -42,6 +42,14 @@ const nativeTextMethods = { */ figureText: descendantText.bind(null, 'figcaption'), + /** + * Return figcaption text of an HTML figure element + * @param {VirtualNode} element + * @param {Object} context + * @return {String} figcaption text + */ + svgTitleText: descendantText.bind(null, 'title'), + /** * Return legend text of an HTML fieldset element * @param {VirtualNode} element diff --git a/lib/core/base/metadata-function-map.js b/lib/core/base/metadata-function-map.js index 3196646263..e42b6d1bfe 100644 --- a/lib/core/base/metadata-function-map.js +++ b/lib/core/base/metadata-function-map.js @@ -129,7 +129,6 @@ import noAutoplayAudioEvaluate from '../../checks/media/no-autoplay-audio-evalua // rule matches import ariaAllowedAttrMatches from '../../rules/aria-allowed-attr-matches'; import ariaAllowedRoleMatches from '../../rules/aria-allowed-role-matches'; -import ariaFormFieldNameMatches from '../../rules/aria-form-field-name-matches'; import ariaHasAttrMatches from '../../rules/aria-has-attr-matches'; import ariaHiddenFocusMatches from '../../rules/aria-hidden-focus-matches'; import autocompleteMatches from '../../rules/autocomplete-matches'; @@ -154,6 +153,7 @@ import layoutTableMatches from '../../rules/layout-table-matches'; import linkInTextBlockMatches from '../../rules/link-in-text-block-matches'; import noAutoplayAudioMatches from '../../rules/no-autoplay-audio-matches'; import noEmptyRoleMatches from '../../rules/no-empty-role-matches'; +import noNamingMethodMatches from '../../rules/no-naming-method-matches'; import noRoleMatches from '../../rules/no-role-matches'; import notHtmlMatches from '../../rules/not-html-matches'; import pAsHeadingMatches from '../../rules/p-as-heading-matches'; @@ -295,7 +295,8 @@ const metadataFunctionMap = { // rule matches 'aria-allowed-attr-matches': ariaAllowedAttrMatches, 'aria-allowed-role-matches': ariaAllowedRoleMatches, - 'aria-form-field-name-matches': ariaFormFieldNameMatches, + // @deprecated + 'aria-form-field-name-matches': noNamingMethodMatches, 'aria-has-attr-matches': ariaHasAttrMatches, 'aria-hidden-focus-matches': ariaHiddenFocusMatches, 'autocomplete-matches': autocompleteMatches, @@ -320,6 +321,7 @@ const metadataFunctionMap = { 'link-in-text-block-matches': linkInTextBlockMatches, 'no-autoplay-audio-matches': noAutoplayAudioMatches, 'no-empty-role-matches': noEmptyRoleMatches, + 'no-naming-method-matches': noNamingMethodMatches, 'no-role-matches': noRoleMatches, 'not-html-matches': notHtmlMatches, 'p-as-heading-matches': pAsHeadingMatches, diff --git a/lib/rules/aria-form-field-name-matches.js b/lib/rules/aria-form-field-name-matches.js deleted file mode 100644 index 0c387f2890..0000000000 --- a/lib/rules/aria-form-field-name-matches.js +++ /dev/null @@ -1,63 +0,0 @@ -import { getExplicitRole } from '../commons/aria'; -import { querySelectorAll } from '../core/utils'; - -function ariaFormFieldNameMatches(node, virtualNode) { - /** - * Note: - * This rule filters elements with 'role=*' attribute via 'selector' - * see relevant rule spec for details of 'role(s)' being filtered. - */ - const nodeName = virtualNode.props.nodeName; - const role = getExplicitRole(virtualNode); - const hasHrefAttr = !!virtualNode.attr('href'); - - /** - * Ignore elements from rule -> 'link-name' - */ - if (nodeName === 'a' && hasHrefAttr) { - return false; - } - - /** - * Ignore elements from rule -> 'area-alt' - */ - if (nodeName === 'area' && hasHrefAttr) { - return false; - } - - /** - * Ignore elements from rule -> 'label' - */ - if (['input', 'select', 'textarea'].includes(nodeName)) { - return false; - } - - /** - * Ignore elements from rule -> 'image-alt' - */ - if (nodeName === 'img' || (role === 'img' && nodeName !== 'svg')) { - return false; - } - - /** - * Ignore elements from rule -> 'button-name' - */ - if (nodeName === 'button' || role === 'button') { - return false; - } - - /** - * Ignore combobox elements if they have a child input - * (ARIA 1.1 pattern) - */ - if ( - role === 'combobox' && - querySelectorAll(virtualNode, 'input:not([type="hidden"])').length - ) { - return false; - } - - return true; -} - -export default ariaFormFieldNameMatches; diff --git a/lib/rules/aria-input-field-name.json b/lib/rules/aria-input-field-name.json index fa683d1468..7804ca77c5 100644 --- a/lib/rules/aria-input-field-name.json +++ b/lib/rules/aria-input-field-name.json @@ -1,7 +1,7 @@ { "id": "aria-input-field-name", "selector": "[role=\"combobox\"], [role=\"listbox\"], [role=\"searchbox\"], [role=\"slider\"], [role=\"spinbutton\"], [role=\"textbox\"]", - "matches": "aria-form-field-name-matches", + "matches": "no-naming-method-matches", "tags": ["cat.aria", "wcag2a", "wcag412"], "metadata": { "description": "Ensures every ARIA input field has an accessible name", diff --git a/lib/rules/aria-progressbar-name.json b/lib/rules/aria-progressbar-name.json index 1f889fdaae..93e6ea0c96 100644 --- a/lib/rules/aria-progressbar-name.json +++ b/lib/rules/aria-progressbar-name.json @@ -1,7 +1,7 @@ { "id": "aria-progressbar-name", "selector": "[role=\"progressbar\"]", - "matches": "aria-form-field-name-matches", + "matches": "no-naming-method-matches", "tags": ["cat.aria", "wcag2a", "wcag111"], "metadata": { "description": "Ensures every ARIA progressbar node has an accessible name", diff --git a/lib/rules/aria-toggle-field-name.json b/lib/rules/aria-toggle-field-name.json index 72ed88a275..baccc7fafc 100644 --- a/lib/rules/aria-toggle-field-name.json +++ b/lib/rules/aria-toggle-field-name.json @@ -1,7 +1,7 @@ { "id": "aria-toggle-field-name", "selector": "[role=\"checkbox\"], [role=\"menuitemcheckbox\"], [role=\"menuitemradio\"], [role=\"radio\"], [role=\"switch\"]", - "matches": "aria-form-field-name-matches", + "matches": "no-naming-method-matches", "tags": ["cat.aria", "wcag2a", "wcag412"], "metadata": { "description": "Ensures every ARIA toggle field has an accessible name", diff --git a/lib/rules/aria-tooltip-name.json b/lib/rules/aria-tooltip-name.json index 895f29bded..11230cf937 100644 --- a/lib/rules/aria-tooltip-name.json +++ b/lib/rules/aria-tooltip-name.json @@ -1,7 +1,7 @@ { "id": "aria-tooltip-name", "selector": "[role=\"tooltip\"]", - "matches": "aria-form-field-name-matches", + "matches": "no-naming-method-matches", "tags": ["cat.aria", "wcag2a", "wcag412"], "metadata": { "description": "Ensures every ARIA tooltip node has an accessible name", diff --git a/lib/rules/no-naming-method-matches.js b/lib/rules/no-naming-method-matches.js new file mode 100644 index 0000000000..d41cea553a --- /dev/null +++ b/lib/rules/no-naming-method-matches.js @@ -0,0 +1,24 @@ +import { getExplicitRole } from '../commons/aria'; +import { querySelectorAll } from '../core/utils'; +import getElementSpec from '../commons/standards/get-element-spec'; + +/** + * Filter out elements that have a naming method (i.e. img[alt], table > caption, etc.) + */ +function noNamingMethodMatches(node, virtualNode) { + const { namingMethods } = getElementSpec(virtualNode); + if (namingMethods && namingMethods.length !== 0) { + return false; + } + + // Additionally, ignore combobox that get their name from a descendant input: + if ( + getExplicitRole(virtualNode) === 'combobox' && + querySelectorAll(virtualNode, 'input:not([type="hidden"])').length + ) { + return false; + } + return true; +} + +export default noNamingMethodMatches; diff --git a/lib/standards/html-elms.js b/lib/standards/html-elms.js index 3b79a1cb1c..ddecbb7447 100644 --- a/lib/standards/html-elms.js +++ b/lib/standards/html-elms.js @@ -27,7 +27,8 @@ const htmlElms = { 'doc-biblioref', 'doc-glossref', 'doc-noteref' - ] + ], + namingMethods: ['subtreeText'] }, // Note: the default variant is a special variant and is // used as the last match if none of the other variants @@ -48,7 +49,8 @@ const htmlElms = { }, area: { contentTypes: ['phrasing', 'flow'], - allowedRoles: false + allowedRoles: false, + namingMethods: ['altText'] }, article: { contentTypes: ['sectioning', 'flow'], @@ -774,7 +776,9 @@ const htmlElms = { contentTypes: ['interactive', 'phrasing', 'flow'], implicitAttrs: { 'aria-valuenow': '' - } + }, + // 5.7 Other form elements + namingMethods: ['labelText'] }, slot: { contentTypes: ['phrasing', 'flow'], @@ -804,7 +808,8 @@ const htmlElms = { }, svg: { contentTypes: ['embedded', 'phrasing', 'flow'], - allowedRoles: ['application', 'document', 'img'] + allowedRoles: ['application', 'document', 'img'], + namingMethods: ['svgTitleText'] }, sub: { contentTypes: ['phrasing', 'flow'], diff --git a/test/commons/text/native-text-methods.js b/test/commons/text/native-text-methods.js index add0d9f1b0..9f93840521 100644 --- a/test/commons/text/native-text-methods.js +++ b/test/commons/text/native-text-methods.js @@ -178,6 +178,41 @@ describe('text.nativeTextMethods', function() { }); }); + describe('svgTitleText', function() { + var svgTitleText = nativeTextMethods.svgTitleText; + it('returns the title text', function() { + fixtureSetup( + '' + ' My title' + ' some content' + '' + ); + var svg = axe.utils.querySelectorAll(axe._tree[0], 'svg')[0]; + assert.equal(svgTitleText(svg), 'My title'); + }); + + it('returns `` when there is no title', function() { + it('returns the title text', function() { + fixtureSetup('' + ' some content' + ''); + var svg = axe.utils.querySelectorAll(axe._tree[0], 'svg')[0]; + assert.equal(svgTitleText(svg), ''); + }); + }); + + it('returns `` when if the title is nested in another svg', function() { + it('returns the title text', function() { + fixtureSetup( + '' + + ' ' + + ' No title' + + ' some content' + + ' ' + + ' some other content' + + '' + ); + var svg = axe.utils.querySelectorAll(axe._tree[0], '#fig1')[0]; + assert.equal(svgTitleText(svg), ''); + }); + }); + }); + describe('singleSpace', function() { var singleSpace = nativeTextMethods.singleSpace; it('returns a single space', function() { diff --git a/test/rule-matches/aria-form-field-name-matches.js b/test/rule-matches/no-naming-method-matches.js similarity index 86% rename from test/rule-matches/aria-form-field-name-matches.js rename to test/rule-matches/no-naming-method-matches.js index f647120a32..75a0485c01 100644 --- a/test/rule-matches/aria-form-field-name-matches.js +++ b/test/rule-matches/no-naming-method-matches.js @@ -1,4 +1,4 @@ -describe('aria-form-field-name-matches', function() { +describe('no-naming-method-matches', function() { 'use strict'; var fixture = document.getElementById('fixture'); @@ -44,20 +44,14 @@ describe('aria-form-field-name-matches', function() { assert.isFalse(actual); }); - it('returns false when node is not SVG with role=`img`', function() { - var vNode = queryFixture('