diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 0734b9dbe3..d97a582dfc 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -145,7 +145,7 @@ Rules we are still testing and developing. They are disabled by default in axe-c | [css-orientation-lock](https://dequeuniversity.com/rules/axe/4.13/css-orientation-lock?application=RuleDescription) | Ensure content is not locked to any specific display orientation, and the content is operable in all display orientations | Serious | cat.structure, wcag134, wcag21aa, EN-301-549, EN-9.1.3.4, RGAAv4, RGAA-13.9.1, experimental | failure, needs review | [b33eff](https://act-rules.github.io/rules/b33eff) | | [focus-order-semantics](https://dequeuniversity.com/rules/axe/4.13/focus-order-semantics?application=RuleDescription) | Ensure elements in the focus order have a role appropriate for interactive content | Minor | cat.keyboard, best-practice, RGAAv4, RGAA-12.8.1, experimental | failure | | | [hidden-content](https://dequeuniversity.com/rules/axe/4.13/hidden-content?application=RuleDescription) | Inform users about hidden content. | Minor | cat.structure, best-practice, experimental, review-item | failure, needs review | | -| [label-content-name-mismatch](https://dequeuniversity.com/rules/axe/4.13/label-content-name-mismatch?application=RuleDescription) | Ensure that elements labelled through their content must have their visible text as part of their accessible name | Serious | cat.semantics, wcag21a, wcag253, EN-301-549, EN-9.2.5.3, RGAAv4, RGAA-6.1.5, experimental | failure | [2ee8b8](https://act-rules.github.io/rules/2ee8b8) | +| [label-content-name-mismatch](https://dequeuniversity.com/rules/axe/4.13/label-content-name-mismatch?application=RuleDescription) | Ensure that elements labelled through their content must have their visible text as part of their accessible name | Serious | cat.semantics, wcag21a, wcag253, EN-301-549, EN-9.2.5.3, RGAAv4, RGAA-6.1.5, experimental | failure, needs review | [2ee8b8](https://act-rules.github.io/rules/2ee8b8) | | [p-as-heading](https://dequeuniversity.com/rules/axe/4.13/p-as-heading?application=RuleDescription) | Ensure bold, italic text and font-size is not used to style <p> elements as a heading | Serious | cat.semantics, wcag2a, wcag131, EN-301-549, EN-9.1.3.1, RGAAv4, RGAA-9.1.3, experimental | failure, needs review | | | [table-fake-caption](https://dequeuniversity.com/rules/axe/4.13/table-fake-caption?application=RuleDescription) | Ensure that tables with a caption use the <caption> element. | Serious | cat.tables, experimental, wcag2a, wcag131, section508, section508.22.g, EN-301-549, EN-9.1.3.1, RGAAv4, RGAA-5.4.1 | failure | | | [td-has-header](https://dequeuniversity.com/rules/axe/4.13/td-has-header?application=RuleDescription) | Ensure that each non-empty data cell in a <table> larger than 3 by 3 has one or more table headers | Critical | cat.tables, experimental, wcag2a, wcag131, section508, section508.22.g, TTv5, TT14.b, EN-301-549, EN-9.1.3.1, RGAAv4, RGAA-5.7.4 | failure | | diff --git a/lib/checks/label/label-content-name-mismatch-evaluate.js b/lib/checks/label/label-content-name-mismatch-evaluate.js index 208dc1afd1..ebc7bcfcef 100644 --- a/lib/checks/label/label-content-name-mismatch-evaluate.js +++ b/lib/checks/label/label-content-name-mismatch-evaluate.js @@ -5,36 +5,73 @@ import { sanitize, visibleVirtual } from '../../commons/text'; +import { getCategoryFormatRegExp } from '../../commons/text/unicode'; /** - * Check if a given text exists in another + * Check whether the visible label's words appear as a contiguous run of words + * within the accessible name. This implements the comparison at the core of ACT + * rule 2ee8b8's "label in name" algorithm: tokenize on non-text characters, then + * match whole words rather than raw substrings. * - * @param {String} compare given text to check - * @param {String} compareWith text against which to be compared + * Note: 2ee8b8's parenthetical-content removal and NFKD normalization steps are + * not implemented (see https://github.com/dequelabs/axe-core/issues/5207). + * + * @param {String} label visible label text + * @param {String} name accessible name + * @param {Object} [options] + * @param {Boolean} [options.joinNonText=false] remove non-text characters + * instead of treating them as word separators, so a difference that is only + * punctuation collapses away * @returns {Boolean} */ -function isStringContained(compare, compareWith) { - const curatedCompareWith = curateString(compareWith); - const curatedCompare = curateString(compare); - if (!curatedCompareWith || !curatedCompare) { +function isLabelContainedInName(label, name, { joinNonText = false } = {}) { + const labelTokens = curateTokens(label, joinNonText); + const nameTokens = curateTokens(name, joinNonText); + if (!labelTokens.length || !nameTokens.length) { return false; } - return curatedCompareWith.includes(curatedCompare); + return isContiguousSubsequence(nameTokens, labelTokens); } /** - * Curate given text, by removing emoji's, punctuations, unicode and trim whitespace. + * Split text into words. Non-text characters (emoji, punctuation, symbols) are + * word separators by default (replaced with a space); when `joinNonText` is set + * they are removed instead, so a punctuation-only difference collapses away. + * Uses `removeUnicode`'s explicit unicode ranges (rather than a `\p{…}` property + * escape) to keep working on the browsers axe supports. * - * @param {String} str given text to curate - * @returns {String} + * @param {String} str given text to tokenize + * @param {Boolean} [joinNonText=false] remove non-text characters instead of + * replacing them with a space + * @returns {String[]} */ -function curateString(str) { - const noUnicodeStr = removeUnicode(str, { +function curateTokens(str, joinNonText = false) { + // Zero-width format characters are invisible, so they can't be word + // boundaries; strip them before handling other non-text characters (otherwise + // a soft hyphen or zero-width space would split a word). + const separated = removeUnicode(str.replace(getCategoryFormatRegExp(), ''), { emoji: true, nonBmp: true, - punctuations: true + punctuations: true, + replaceWith: joinNonText ? '' : ' ' }); - return sanitize(noUnicodeStr); + return sanitize(separated).split(/\s+/).filter(Boolean); +} + +/** + * Whether `needle` appears as a contiguous run within `haystack`. + * + * @param {String[]} haystack + * @param {String[]} needle + * @returns {Boolean} + */ +function isContiguousSubsequence(haystack, needle) { + for (let i = 0; i + needle.length <= haystack.length; i++) { + if (needle.every((word, j) => word === haystack[i + j])) { + return true; + } + } + return false; } function labelContentNameMismatchEvaluate(node, options, virtualNode) { @@ -59,7 +96,25 @@ function labelContentNameMismatchEvaluate(node, options, virtualNode) { return undefined; } - return isStringContained(visibleText, accText); + if (isLabelContainedInName(visibleText, accText)) { + return true; + } + + // ACT rule 2ee8b8 treats punctuation-only differences (e.g. hyphenation) as + // inapplicable. When the label is contained in the name once non-text + // characters are removed rather than treated as word separators, the only + // difference is punctuation, so return undefined (needs review) instead of a + // violation. + // + // TODO(#5203): the incomplete result here is load-bearing only while the + // pinned wcag-act-rules dep is stale. When the dep bump tracks `main`, + // revisit whether these should stay incomplete or stop matching the rule. + if (isLabelContainedInName(visibleText, accText, { joinNonText: true })) { + this.data({ messageKey: 'punctuation' }); + return undefined; + } + + return false; } export default labelContentNameMismatchEvaluate; diff --git a/lib/checks/label/label-content-name-mismatch.json b/lib/checks/label/label-content-name-mismatch.json index 872ca0a6c6..a34c998111 100644 --- a/lib/checks/label/label-content-name-mismatch.json +++ b/lib/checks/label/label-content-name-mismatch.json @@ -9,7 +9,11 @@ "impact": "serious", "messages": { "pass": "Element contains visible text as part of it's accessible name", - "fail": "Text inside the element is not included in the accessible name" + "fail": "Text inside the element is not included in the accessible name", + "incomplete": { + "default": "Unable to determine if the visible text is part of the accessible name", + "punctuation": "The visible text and the accessible name differ only in punctuation" + } } } } diff --git a/lib/commons/text/remove-unicode.js b/lib/commons/text/remove-unicode.js index d455bc4136..4fe5ed113c 100644 --- a/lib/commons/text/remove-unicode.js +++ b/lib/commons/text/remove-unicode.js @@ -17,22 +17,27 @@ import { emojiRegexText } from '../../core/imports'; * @property {Boolean} options.emoji remove emoji unicode * @property {Boolean} options.nonBmp remove nonBmp unicode * @property {Boolean} options.punctuations remove punctuations unicode + * @property {String} [options.replaceWith=''] literal string to substitute for each matched character (e.g. a space to preserve word boundaries) * @returns {String} */ function removeUnicode(str, options) { - const { emoji, nonBmp, punctuations } = options; + const { emoji, nonBmp, punctuations, replaceWith = '' } = options; + // Use a replacer function so `replaceWith` is always inserted literally, i.e. + // `$&`, `$'`, `$1`, etc. are not interpreted as `String.prototype.replace` + // patterns. + const replacer = () => replaceWith; if (emoji) { - str = str.replace(emojiRegexText(), ''); + str = str.replace(emojiRegexText(), replacer); } if (nonBmp) { str = str - .replace(getUnicodeNonBmpRegExp(), '') - .replace(getSupplementaryPrivateUseRegExp(), '') - .replace(getCategoryFormatRegExp(), ''); + .replace(getUnicodeNonBmpRegExp(), replacer) + .replace(getSupplementaryPrivateUseRegExp(), replacer) + .replace(getCategoryFormatRegExp(), replacer); } if (punctuations) { - str = str.replace(getPunctuationRegExp(), ''); + str = str.replace(getPunctuationRegExp(), replacer); } return str; diff --git a/lib/core/utils/publish-metadata.js b/lib/core/utils/publish-metadata.js index 3ab459ccb5..205d73f6a4 100644 --- a/lib/core/utils/publish-metadata.js +++ b/lib/core/utils/publish-metadata.js @@ -59,7 +59,11 @@ function getIncompleteReason(checkData, messages) { } } } else if (checkData && checkData.messageKey) { - return messages.incomplete[checkData.messageKey]; + // Localized bundles replace `messages` with a locale entry that may omit + // `incomplete`, so fall back to the default rather than throwing. + return ( + messages.incomplete?.[checkData.messageKey] ?? getDefaultMsg(messages) + ); } else { return getDefaultMsg(messages); } @@ -88,9 +92,11 @@ function extender(checksData, shouldBeTrue, rule) { data.message = getIncompleteReason(check.data, messages); } - // fallback to new process message style + // fallback to new process message style; localized bundles may omit + // `incomplete` entirely, so fall back to the default rather than leaving + // the message undefined. if (!data.message) { - data.message = messages.incomplete; + data.message = messages.incomplete ?? incompleteFallbackMessage(); } } else { data.message = diff --git a/locales/_template.json b/locales/_template.json index 0f38239075..499c7748f8 100644 --- a/locales/_template.json +++ b/locales/_template.json @@ -817,7 +817,11 @@ }, "label-content-name-mismatch": { "pass": "Element contains visible text as part of it's accessible name", - "fail": "Text inside the element is not included in the accessible name" + "fail": "Text inside the element is not included in the accessible name", + "incomplete": { + "default": "Unable to determine if the visible text is part of the accessible name", + "punctuation": "The visible text and the accessible name differ only in punctuation" + } }, "multiple-label": { "pass": "Form field does not have multiple label elements", diff --git a/test/act-rules/visible-label-in-accessible-name-2ee8b8.spec.js b/test/act-rules/visible-label-in-accessible-name-2ee8b8.spec.js index 8a799382f6..9d03a968a0 100644 --- a/test/act-rules/visible-label-in-accessible-name-2ee8b8.spec.js +++ b/test/act-rules/visible-label-in-accessible-name-2ee8b8.spec.js @@ -3,12 +3,13 @@ require('./act-runner.js')({ title: 'Visible label is part of accessible name', axeRules: ['label-content-name-mismatch'], skipTests: [ - // See: https://github.com/dequelabs/axe-core/issues/4311 - 'e9bbdbec137223e2973c6d2896050770c84c26e5', // See: https://github.com/dequelabs/axe-core/issues/5207 - 'fab659b02c1edb4f2c8f0bda524b1076abab7df6', - '94a7ce7aea9dbfaa375c459c26d3a5923de84e7a', - 'e117393d6711d6bdf32821005219c9d9474dfeb8', - 'f5c9811c984987443476760a1c5b91b1067f7e19' + 'fab659b02c1edb4f2c8f0bda524b1076abab7df6', // Passed Example 11 + '94a7ce7aea9dbfaa375c459c26d3a5923de84e7a', // Passed Example 14 + 'e117393d6711d6bdf32821005219c9d9474dfeb8', // Failed Example 3 + 'f5c9811c984987443476760a1c5b91b1067f7e19', // Failed Example 15 + // Abbreviation differences are inapplicable under ACT rule 2ee8b8 but cannot + // be reliably detected. See: https://github.com/dequelabs/axe-core/issues/4821 + '4c8c38022d15c92158ecaaa647fe8ca2c330f485' // Inapplicable Example 5 ] }); diff --git a/test/checks/label/label-content-name-mismatch.js b/test/checks/label/label-content-name-mismatch.js index 8cc8f8224d..f27105a371 100644 --- a/test/checks/label/label-content-name-mismatch.js +++ b/test/checks/label/label-content-name-mismatch.js @@ -2,11 +2,17 @@ describe('label-content-name-mismatch tests', () => { const html = axe.testUtils.html; const queryFixture = axe.testUtils.queryFixture; + const queryShadowFixture = axe.testUtils.queryShadowFixture; const check = checks['label-content-name-mismatch']; + const checkContext = new axe.testUtils.MockCheckContext(); const options = undefined; const fontApiSupport = !!document.fonts; + afterEach(() => { + checkContext.reset(); + }); + before(done => { if (!fontApiSupport) { done(); @@ -187,6 +193,14 @@ describe('label-content-name-mismatch tests', () => { assert.isTrue(actual); }); + it('treats a lone newline in the visible text as a word separator', () => { + const vNode = queryFixture( + '' + ); + const actual = check.evaluate(vNode.actualNode, options, vNode); + assert.isTrue(actual); + }); + it('returns true when aria-label and visible text match even though there is an image with alt text', function () { var vNode = queryFixture( '' @@ -224,4 +238,58 @@ describe('label-content-name-mismatch tests', () => { assert.isFalse(actual); } ); + + it('returns false when the visible words are not a contiguous run within the accessible name', () => { + const vNode = queryFixture( + '' + ); + const actual = check.evaluate(vNode.actualNode, options, vNode); + assert.isFalse(actual); + }); + + it('returns true when the visible words are a contiguous run within the accessible name', () => { + const vNode = queryFixture( + '' + ); + const actual = check.evaluate(vNode.actualNode, options, vNode); + assert.isTrue(actual); + }); + + it('ignores zero-width characters when tokenizing so they do not split a word', () => { + const vNode = queryFixture( + 'non\u00ADstandard' + ); + const actual = check.evaluate(vNode.actualNode, options, vNode); + assert.isTrue(actual); + }); + + [ + ['a hyphen', 'non-standard', 'nonstandard'], + ['an apostrophe', 'its book', "it's"], + ['periods', 'usa', 'u.s.a'], + ['an en dash', 'email', 'e–mail'] + ].forEach(([label, name, content]) => { + it(`returns undefined (needs review) when the only difference is ${label}`, () => { + const vNode = queryFixture( + `${content}` + ); + const actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); + assert.isUndefined(actual); + assert.deepEqual(checkContext._data, { messageKey: 'punctuation' }); + }); + }); + + it('matches the visible label against the accessible name across an open shadow DOM boundary', () => { + const vNode = queryShadowFixture( + '', + 'save' + ); + const actual = check.evaluate(vNode.actualNode, options, vNode); + assert.isTrue(actual); + }); }); diff --git a/test/commons/text/unicode.js b/test/commons/text/unicode.js index 35f3115bc5..b4f0e8d9b9 100644 --- a/test/commons/text/unicode.js +++ b/test/commons/text/unicode.js @@ -270,4 +270,36 @@ describe('text.removeUnicode', () => { }); assert.equal(actual, 'Hello World'); }); + + it('substitutes matched punctuation with replaceWith when provided', () => { + const actual = axe.commons.text.removeUnicode('non-standard', { + punctuations: true, + replaceWith: ' ' + }); + assert.equal(actual, 'non standard'); + }); + + it('substitutes matched emoji with replaceWith when provided', () => { + const actual = axe.commons.text.removeUnicode('Sun🌎Earth', { + emoji: true, + replaceWith: ' ' + }); + assert.equal(actual, 'Sun Earth'); + }); + + it('substitutes matched non BMP characters with replaceWith when provided', () => { + const actual = axe.commons.text.removeUnicode('20000₨100', { + nonBmp: true, + replaceWith: ' ' + }); + assert.equal(actual, '20000 100'); + }); + + it('inserts replaceWith literally rather than as a replace pattern', () => { + const actual = axe.commons.text.removeUnicode('a😀b', { + emoji: true, + replaceWith: '$&' + }); + assert.equal(actual, 'a$&b'); + }); }); diff --git a/test/core/utils/publish-metadata.js b/test/core/utils/publish-metadata.js index c13abcb88c..291903ca09 100644 --- a/test/core/utils/publish-metadata.js +++ b/test/core/utils/publish-metadata.js @@ -457,6 +457,50 @@ describe('axe.utils.publishMetaData', () => { }); }); + it('should fall back to a default message when a check sets a messageKey but has no incomplete messages', () => { + // Localized bundles replace a check's messages with a locale entry that may + // omit `incomplete`; a messageKey lookup must not throw. + axe._load({ + rules: [], + data: { + incompleteFallbackMessage: () => 'fallback message', + rules: { + cats: { + help: () => 'cats-rule' + } + }, + checks: { + 'cats-ANY': { + messages: { + fail: () => 'fail-ANY', + pass: () => 'pass-ANY' + } + } + } + } + }); + + const result = { + id: 'cats', + nodes: [ + { + any: [ + { + result: undefined, + id: 'cats-ANY', + data: { messageKey: 'punctuation' } + } + ], + none: [], + all: [] + } + ] + }; + + assert.doesNotThrow(() => axe.utils.publishMetaData(result)); + assert.equal(result.nodes[0].any[0].message, 'fallback message'); + }); + it('should handle incomplete reasons', () => { axe._load({ rules: [], diff --git a/test/integration/rules/label-content-name-mismatch/label-content-name-mismatch.html b/test/integration/rules/label-content-name-mismatch/label-content-name-mismatch.html index 5797193417..78d7da5d4b 100644 --- a/test/integration/rules/label-content-name-mismatch/label-content-name-mismatch.html +++ b/test/integration/rules/label-content-name-mismatch/label-content-name-mismatch.html @@ -66,6 +66,8 @@ +nonstandard + Next diff --git a/test/integration/rules/label-content-name-mismatch/label-content-name-mismatch.json b/test/integration/rules/label-content-name-mismatch/label-content-name-mismatch.json index 665c46772a..9abd649483 100644 --- a/test/integration/rules/label-content-name-mismatch/label-content-name-mismatch.json +++ b/test/integration/rules/label-content-name-mismatch/label-content-name-mismatch.json @@ -38,6 +38,7 @@ ["#incomplete14"], ["#incomplete15"], ["#incomplete16"], - ["#incomplete17"] + ["#incomplete17"], + ["#incomplete18"] ] }