From 6aa5e7184404c7a9b07c26594d1017fb1837761c Mon Sep 17 00:00:00 2001 From: Chris Hutchins Date: Fri, 7 Aug 2026 14:53:06 -0400 Subject: [PATCH 1/5] fix(label-content-name-mismatch): compare visible label to name by word Follow ACT rule 2ee8b8's "label in name" algorithm: treat non-text characters as word separators (replacing them with a space instead of removing them) and require the visible label's words to appear as a contiguous run within the accessible name, rather than matching as a raw substring. Hyphenated labels like "non-standard" no longer match "nonstandard". Add a replaceWith option to removeUnicode to preserve word boundaries. Closes issue #4311 --- .../label-content-name-mismatch-evaluate.js | 48 ++++++++++++++----- lib/commons/text/remove-unicode.js | 13 ++--- ...le-label-in-accessible-name-2ee8b8.spec.js | 10 +--- .../label/label-content-name-mismatch.js | 32 +++++++++++++ test/commons/text/unicode.js | 8 ++++ .../label-content-name-mismatch.html | 1 + .../label-content-name-mismatch.json | 3 +- 7 files changed, 87 insertions(+), 28 deletions(-) diff --git a/lib/checks/label/label-content-name-mismatch-evaluate.js b/lib/checks/label/label-content-name-mismatch-evaluate.js index 208dc1afd1..1373630b47 100644 --- a/lib/checks/label/label-content-name-mismatch-evaluate.js +++ b/lib/checks/label/label-content-name-mismatch-evaluate.js @@ -7,34 +7,58 @@ import { } from '../../commons/text'; /** - * Check if a given text exists in another + * Check whether the words of `compare` appear as a contiguous run of words + * within `compareWith`, following ACT rule 2ee8b8's "label in name" algorithm: + * non-letter/non-digit characters are treated as word separators and the + * comparison is done on whole words, not raw substrings. * * @param {String} compare given text to check * @param {String} compareWith text against which to be compared * @returns {Boolean} */ function isStringContained(compare, compareWith) { - const curatedCompareWith = curateString(compareWith); - const curatedCompare = curateString(compare); - if (!curatedCompareWith || !curatedCompare) { + const compareTokens = curateTokens(compare); + const compareWithTokens = curateTokens(compareWith); + if (!compareTokens.length || !compareWithTokens.length) { return false; } - return curatedCompareWith.includes(curatedCompare); + return isContiguousSubsequence(compareWithTokens, compareTokens); } /** - * Curate given text, by removing emoji's, punctuations, unicode and trim whitespace. + * Tokenize text the way ACT rule 2ee8b8's "label in name" algorithm does: + * treat non-text characters (emoji, punctuation, symbols) as word separators + * by replacing them with a space, then split into words. Uses `removeUnicode`'s + * explicit unicode ranges (rather than a `\p{…}` property escape) to keep the + * comparison working on the browsers axe supports. * - * @param {String} str given text to curate - * @returns {String} + * @param {String} str given text to tokenize + * @returns {String[]} */ -function curateString(str) { - const noUnicodeStr = removeUnicode(str, { +function curateTokens(str) { + const separated = removeUnicode(str, { emoji: true, nonBmp: true, - punctuations: true + punctuations: true, + replaceWith: ' ' }); - return sanitize(noUnicodeStr); + return sanitize(separated).split(' ').filter(Boolean); +} + +/** + * Whether `sub` appears as a contiguous run within `sequence`. + * + * @param {String[]} sequence + * @param {String[]} sub + * @returns {Boolean} + */ +function isContiguousSubsequence(sequence, sub) { + for (let i = 0; i + sub.length <= sequence.length; i++) { + if (sub.every((word, j) => word === sequence[i + j])) { + return true; + } + } + return false; } function labelContentNameMismatchEvaluate(node, options, virtualNode) { diff --git a/lib/commons/text/remove-unicode.js b/lib/commons/text/remove-unicode.js index d455bc4136..cbe20d8850 100644 --- a/lib/commons/text/remove-unicode.js +++ b/lib/commons/text/remove-unicode.js @@ -17,22 +17,23 @@ 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=''] 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; if (emoji) { - str = str.replace(emojiRegexText(), ''); + str = str.replace(emojiRegexText(), replaceWith); } if (nonBmp) { str = str - .replace(getUnicodeNonBmpRegExp(), '') - .replace(getSupplementaryPrivateUseRegExp(), '') - .replace(getCategoryFormatRegExp(), ''); + .replace(getUnicodeNonBmpRegExp(), replaceWith) + .replace(getSupplementaryPrivateUseRegExp(), replaceWith) + .replace(getCategoryFormatRegExp(), replaceWith); } if (punctuations) { - str = str.replace(getPunctuationRegExp(), ''); + str = str.replace(getPunctuationRegExp(), replaceWith); } return str; 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..f35b5a8b3a 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 @@ -2,13 +2,5 @@ require('./act-runner.js')({ id: '2ee8b8', 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' - ] + skipTests: [] }); diff --git a/test/checks/label/label-content-name-mismatch.js b/test/checks/label/label-content-name-mismatch.js index 8cc8f8224d..ea37056a0f 100644 --- a/test/checks/label/label-content-name-mismatch.js +++ b/test/checks/label/label-content-name-mismatch.js @@ -224,4 +224,36 @@ describe('label-content-name-mismatch tests', () => { assert.isFalse(actual); } ); + + it('returns false when a hyphen joins words that the accessible name keeps separate', () => { + const vNode = queryFixture( + 'nonstandard' + ); + const actual = check.evaluate(vNode.actualNode, options, vNode); + assert.isFalse(actual); + }); + + it('returns false when visible text is a single word not present as a whole word in the accessible name', () => { + const vNode = queryFixture( + 'e-mail' + ); + const actual = check.evaluate(vNode.actualNode, options, vNode); + 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); + }); }); diff --git a/test/commons/text/unicode.js b/test/commons/text/unicode.js index 35f3115bc5..1554f1d979 100644 --- a/test/commons/text/unicode.js +++ b/test/commons/text/unicode.js @@ -270,4 +270,12 @@ describe('text.removeUnicode', () => { }); assert.equal(actual, 'Hello World'); }); + + it('substitutes matched characters with replaceWith when provided', () => { + const actual = axe.commons.text.removeUnicode('non-standard', { + punctuations: true, + replaceWith: ' ' + }); + assert.equal(actual, 'non standard'); + }); }); 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..8ba183d182 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 @@ -46,6 +46,7 @@ Hello Deque Systems +nonstandard 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..d1739f8c85 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 @@ -8,7 +8,8 @@ ["#fail4"], ["#fail5"], ["#fail6"], - ["#fail7"] + ["#fail7"], + ["#fail8"] ], "passes": [ ["#pass1"], From ca8cde3c6c6ce12a60b70a7427ceefbba343efd0 Mon Sep 17 00:00:00 2001 From: Chris Hutchins Date: Mon, 10 Aug 2026 13:11:00 -0400 Subject: [PATCH 2/5] fix(label-content-name-mismatch): split label tokens on all whitespace sanitize() only collapses runs of two or more whitespace characters, so a lone newline or tab could keep two words in one token. Split on any whitespace so the word-level comparison stays correct. --- lib/checks/label/label-content-name-mismatch-evaluate.js | 2 +- test/checks/label/label-content-name-mismatch.js | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/checks/label/label-content-name-mismatch-evaluate.js b/lib/checks/label/label-content-name-mismatch-evaluate.js index 1373630b47..8705819e17 100644 --- a/lib/checks/label/label-content-name-mismatch-evaluate.js +++ b/lib/checks/label/label-content-name-mismatch-evaluate.js @@ -42,7 +42,7 @@ function curateTokens(str) { punctuations: true, replaceWith: ' ' }); - return sanitize(separated).split(' ').filter(Boolean); + return sanitize(separated).split(/\s+/).filter(Boolean); } /** diff --git a/test/checks/label/label-content-name-mismatch.js b/test/checks/label/label-content-name-mismatch.js index ea37056a0f..a39277354b 100644 --- a/test/checks/label/label-content-name-mismatch.js +++ b/test/checks/label/label-content-name-mismatch.js @@ -187,6 +187,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( '' From 341603aa7117aadf57be9fd4cf5ecdc030276191 Mon Sep 17 00:00:00 2001 From: Chris Hutchins Date: Tue, 11 Aug 2026 09:50:15 -0400 Subject: [PATCH 3/5] fix(label-content-name-mismatch): treat hyphenation differences as inapplicable Per ACT rule 2ee8b8, a difference that is only hyphenation is inapplicable rather than a failure. Return undefined (needs review) when the label is contained in the name once hyphens are removed, so "non-standard" and "nonstandard" no longer report a violation. Also compare labels by whole words (contiguous run) rather than raw substring, rename isStringContained to isLabelContainedInName, and add a replaceWith option to removeUnicode. Closes issue #4311 --- .../label-content-name-mismatch-evaluate.js | 74 +++++++++++++------ ...le-label-in-accessible-name-2ee8b8.spec.js | 11 ++- .../label/label-content-name-mismatch.js | 22 +++--- test/commons/text/unicode.js | 18 ++++- .../label-content-name-mismatch.html | 3 +- .../label-content-name-mismatch.json | 6 +- 6 files changed, 94 insertions(+), 40 deletions(-) diff --git a/lib/checks/label/label-content-name-mismatch-evaluate.js b/lib/checks/label/label-content-name-mismatch-evaluate.js index 8705819e17..8ab84b54b1 100644 --- a/lib/checks/label/label-content-name-mismatch-evaluate.js +++ b/lib/checks/label/label-content-name-mismatch-evaluate.js @@ -7,30 +7,32 @@ import { } from '../../commons/text'; /** - * Check whether the words of `compare` appear as a contiguous run of words - * within `compareWith`, following ACT rule 2ee8b8's "label in name" algorithm: - * non-letter/non-digit characters are treated as word separators and the - * comparison is done on whole words, not raw substrings. + * 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 * @returns {Boolean} */ -function isStringContained(compare, compareWith) { - const compareTokens = curateTokens(compare); - const compareWithTokens = curateTokens(compareWith); - if (!compareTokens.length || !compareWithTokens.length) { +function isLabelContainedInName(label, name) { + const labelTokens = curateTokens(label); + const nameTokens = curateTokens(name); + if (!labelTokens.length || !nameTokens.length) { return false; } - return isContiguousSubsequence(compareWithTokens, compareTokens); + return isContiguousSubsequence(nameTokens, labelTokens); } /** - * Tokenize text the way ACT rule 2ee8b8's "label in name" algorithm does: - * treat non-text characters (emoji, punctuation, symbols) as word separators - * by replacing them with a space, then split into words. Uses `removeUnicode`'s - * explicit unicode ranges (rather than a `\p{…}` property escape) to keep the - * comparison working on the browsers axe supports. + * Split text into words, treating non-text characters (emoji, punctuation, + * symbols) as separators by replacing them with a space. 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 tokenize * @returns {String[]} @@ -46,21 +48,33 @@ function curateTokens(str) { } /** - * Whether `sub` appears as a contiguous run within `sequence`. + * Whether `needle` appears as a contiguous run within `haystack`. * - * @param {String[]} sequence - * @param {String[]} sub + * @param {String[]} haystack + * @param {String[]} needle * @returns {Boolean} */ -function isContiguousSubsequence(sequence, sub) { - for (let i = 0; i + sub.length <= sequence.length; i++) { - if (sub.every((word, j) => word === sequence[i + j])) { +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; } +/** + * Remove hyphens so a hyphenated word collapses into a single word (e.g. + * "non-standard" becomes "nonstandard"). Used to detect when the only + * difference between the label and the name is hyphenation. + * + * @param {String} str + * @returns {String} + */ +function removeHyphens(str) { + return str.replace(/[-‐‑]/g, ''); +} + function labelContentNameMismatchEvaluate(node, options, virtualNode) { const pixelThreshold = options?.pixelThreshold; const occurrenceThreshold = @@ -83,7 +97,21 @@ function labelContentNameMismatchEvaluate(node, options, virtualNode) { return undefined; } - return isStringContained(visibleText, accText); + if (isLabelContainedInName(visibleText, accText)) { + return true; + } + + // ACT rule 2ee8b8 treats hyphenation differences as inapplicable. When the + // label is contained in the name once hyphens are removed rather than treated + // as word separators, the only difference is hyphenation, so return undefined + // (needs review) instead of a violation. + if ( + isLabelContainedInName(removeHyphens(visibleText), removeHyphens(accText)) + ) { + return undefined; + } + + return false; } export default labelContentNameMismatchEvaluate; 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 f35b5a8b3a..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 @@ -2,5 +2,14 @@ require('./act-runner.js')({ id: '2ee8b8', title: 'Visible label is part of accessible name', axeRules: ['label-content-name-mismatch'], - skipTests: [] + skipTests: [ + // See: https://github.com/dequelabs/axe-core/issues/5207 + '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 a39277354b..fe9f3edd65 100644 --- a/test/checks/label/label-content-name-mismatch.js +++ b/test/checks/label/label-content-name-mismatch.js @@ -233,35 +233,35 @@ describe('label-content-name-mismatch tests', () => { } ); - it('returns false when a hyphen joins words that the accessible name keeps separate', () => { + it('returns false when the visible words are not a contiguous run within the accessible name', () => { const vNode = queryFixture( - 'nonstandard' + '' ); const actual = check.evaluate(vNode.actualNode, options, vNode); assert.isFalse(actual); }); - it('returns false when visible text is a single word not present as a whole word in the accessible name', () => { + it('returns true when the visible words are a contiguous run within the accessible name', () => { const vNode = queryFixture( - 'e-mail' + '' ); const actual = check.evaluate(vNode.actualNode, options, vNode); - assert.isFalse(actual); + assert.isTrue(actual); }); - it('returns false when the visible words are not a contiguous run within the accessible name', () => { + it('returns undefined (needs review) when the only difference is hyphenation', () => { const vNode = queryFixture( - '' + 'nonstandard' ); const actual = check.evaluate(vNode.actualNode, options, vNode); - assert.isFalse(actual); + assert.isUndefined(actual); }); - it('returns true when the visible words are a contiguous run within the accessible name', () => { + it('returns undefined (needs review) when a hyphenated visible word matches the accessible name', () => { const vNode = queryFixture( - '' + 'e-mail' ); const actual = check.evaluate(vNode.actualNode, options, vNode); - assert.isTrue(actual); + assert.isUndefined(actual); }); }); diff --git a/test/commons/text/unicode.js b/test/commons/text/unicode.js index 1554f1d979..95b662e35f 100644 --- a/test/commons/text/unicode.js +++ b/test/commons/text/unicode.js @@ -271,11 +271,27 @@ describe('text.removeUnicode', () => { assert.equal(actual, 'Hello World'); }); - it('substitutes matched characters with replaceWith when provided', () => { + 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'); + }); }); 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 8ba183d182..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 @@ -46,7 +46,6 @@ Hello Deque Systems -nonstandard @@ -67,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 d1739f8c85..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 @@ -8,8 +8,7 @@ ["#fail4"], ["#fail5"], ["#fail6"], - ["#fail7"], - ["#fail8"] + ["#fail7"] ], "passes": [ ["#pass1"], @@ -39,6 +38,7 @@ ["#incomplete14"], ["#incomplete15"], ["#incomplete16"], - ["#incomplete17"] + ["#incomplete17"], + ["#incomplete18"] ] } From 0c94924291c4d8467053476194086886fda7f16c Mon Sep 17 00:00:00 2001 From: Chris Hutchins Date: Tue, 11 Aug 2026 12:22:28 -0400 Subject: [PATCH 4/5] fix(label-content-name-mismatch): refine hyphenation and zero-width handling Strip zero-width format characters before tokenizing so they can't create a word boundary, widen the dash family removeHyphens covers to match the tokenizer, and add a templated "differ only in hyphenation" incomplete message so the needs-review result explains itself. --- doc/rule-descriptions.md | 2 +- .../label-content-name-mismatch-evaluate.js | 15 ++++++++-- .../label/label-content-name-mismatch.json | 6 +++- locales/_template.json | 6 +++- .../label/label-content-name-mismatch.js | 29 +++++++++++++++++-- 5 files changed, 51 insertions(+), 7 deletions(-) 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 8ab84b54b1..32a2f8f0f3 100644 --- a/lib/checks/label/label-content-name-mismatch-evaluate.js +++ b/lib/checks/label/label-content-name-mismatch-evaluate.js @@ -5,6 +5,7 @@ import { sanitize, visibleVirtual } from '../../commons/text'; +import { getCategoryFormatRegExp } from '../../commons/text/unicode'; /** * Check whether the visible label's words appear as a contiguous run of words @@ -38,7 +39,10 @@ function isLabelContainedInName(label, name) { * @returns {String[]} */ function curateTokens(str) { - const separated = removeUnicode(str, { + // Zero-width format characters are invisible, so they can't be word + // boundaries; strip them before replacing other non-text characters with + // spaces (otherwise a soft hyphen or zero-width space would split a word). + const separated = removeUnicode(str.replace(getCategoryFormatRegExp(), ''), { emoji: true, nonBmp: true, punctuations: true, @@ -72,7 +76,9 @@ function isContiguousSubsequence(haystack, needle) { * @returns {String} */ function removeHyphens(str) { - return str.replace(/[-‐‑]/g, ''); + // The whole dash family, so it stays consistent with the dashes that + // `getPunctuationRegExp` treats as word separators during tokenizing. + return str.replace(/[\u002D\u2010-\u2015\u2212]/g, ''); } function labelContentNameMismatchEvaluate(node, options, virtualNode) { @@ -105,9 +111,14 @@ function labelContentNameMismatchEvaluate(node, options, virtualNode) { // label is contained in the name once hyphens are removed rather than treated // as word separators, the only difference is hyphenation, 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(removeHyphens(visibleText), removeHyphens(accText)) ) { + this.data({ messageKey: 'hyphenation' }); return undefined; } diff --git a/lib/checks/label/label-content-name-mismatch.json b/lib/checks/label/label-content-name-mismatch.json index 872ca0a6c6..072f01418a 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", + "hyphenation": "The visible text and the accessible name differ only in hyphenation" + } } } } diff --git a/locales/_template.json b/locales/_template.json index 0f38239075..e130b32ec7 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", + "hyphenation": "The visible text and the accessible name differ only in hyphenation" + } }, "multiple-label": { "pass": "Form field does not have multiple label elements", diff --git a/test/checks/label/label-content-name-mismatch.js b/test/checks/label/label-content-name-mismatch.js index fe9f3edd65..8382da149a 100644 --- a/test/checks/label/label-content-name-mismatch.js +++ b/test/checks/label/label-content-name-mismatch.js @@ -3,10 +3,15 @@ describe('label-content-name-mismatch tests', () => { const queryFixture = axe.testUtils.queryFixture; 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(); @@ -249,19 +254,39 @@ describe('label-content-name-mismatch tests', () => { 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); + }); + it('returns undefined (needs review) when the only difference is hyphenation', () => { const vNode = queryFixture( 'nonstandard' ); - const actual = check.evaluate(vNode.actualNode, options, vNode); + const actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); assert.isUndefined(actual); + assert.deepEqual(checkContext._data, { messageKey: 'hyphenation' }); }); it('returns undefined (needs review) when a hyphenated visible word matches the accessible name', () => { const vNode = queryFixture( 'e-mail' ); - const actual = check.evaluate(vNode.actualNode, options, vNode); + const actual = check.evaluate.call( + checkContext, + vNode.actualNode, + options, + vNode + ); assert.isUndefined(actual); + assert.deepEqual(checkContext._data, { messageKey: 'hyphenation' }); }); }); From 1ebec56706f9ad4b26d5a365c94bd17fa403fbc9 Mon Sep 17 00:00:00 2001 From: Chris Hutchins Date: Thu, 13 Aug 2026 08:59:04 -0400 Subject: [PATCH 5/5] fix(label-content-name-mismatch): generalize punctuation carve-out and harden messages Treat any punctuation-only difference (not just hyphenation) as incomplete rather than a violation, so apostrophes, periods and dashes no longer flip labels like "it's"/"its book" to false positives. Fall back to a default incomplete message when a localized bundle omits one, and make removeUnicode's replaceWith a literal insertion. --- .../label-content-name-mismatch-evaluate.js | 58 ++++++++----------- .../label/label-content-name-mismatch.json | 2 +- lib/commons/text/remove-unicode.js | 16 +++-- lib/core/utils/publish-metadata.js | 12 +++- locales/_template.json | 2 +- .../label/label-content-name-mismatch.js | 49 ++++++++-------- test/commons/text/unicode.js | 8 +++ test/core/utils/publish-metadata.js | 44 ++++++++++++++ 8 files changed, 124 insertions(+), 67 deletions(-) diff --git a/lib/checks/label/label-content-name-mismatch-evaluate.js b/lib/checks/label/label-content-name-mismatch-evaluate.js index 32a2f8f0f3..ebc7bcfcef 100644 --- a/lib/checks/label/label-content-name-mismatch-evaluate.js +++ b/lib/checks/label/label-content-name-mismatch-evaluate.js @@ -18,11 +18,15 @@ import { getCategoryFormatRegExp } from '../../commons/text/unicode'; * * @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 isLabelContainedInName(label, name) { - const labelTokens = curateTokens(label); - const nameTokens = curateTokens(name); +function isLabelContainedInName(label, name, { joinNonText = false } = {}) { + const labelTokens = curateTokens(label, joinNonText); + const nameTokens = curateTokens(name, joinNonText); if (!labelTokens.length || !nameTokens.length) { return false; } @@ -30,23 +34,26 @@ function isLabelContainedInName(label, name) { } /** - * Split text into words, treating non-text characters (emoji, punctuation, - * symbols) as separators by replacing them with a space. Uses `removeUnicode`'s - * explicit unicode ranges (rather than a `\p{…}` property escape) to keep - * working on the browsers axe supports. + * 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 tokenize + * @param {Boolean} [joinNonText=false] remove non-text characters instead of + * replacing them with a space * @returns {String[]} */ -function curateTokens(str) { +function curateTokens(str, joinNonText = false) { // Zero-width format characters are invisible, so they can't be word - // boundaries; strip them before replacing other non-text characters with - // spaces (otherwise a soft hyphen or zero-width space would split a 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, - replaceWith: ' ' + replaceWith: joinNonText ? '' : ' ' }); return sanitize(separated).split(/\s+/).filter(Boolean); } @@ -67,20 +74,6 @@ function isContiguousSubsequence(haystack, needle) { return false; } -/** - * Remove hyphens so a hyphenated word collapses into a single word (e.g. - * "non-standard" becomes "nonstandard"). Used to detect when the only - * difference between the label and the name is hyphenation. - * - * @param {String} str - * @returns {String} - */ -function removeHyphens(str) { - // The whole dash family, so it stays consistent with the dashes that - // `getPunctuationRegExp` treats as word separators during tokenizing. - return str.replace(/[\u002D\u2010-\u2015\u2212]/g, ''); -} - function labelContentNameMismatchEvaluate(node, options, virtualNode) { const pixelThreshold = options?.pixelThreshold; const occurrenceThreshold = @@ -107,18 +100,17 @@ function labelContentNameMismatchEvaluate(node, options, virtualNode) { return true; } - // ACT rule 2ee8b8 treats hyphenation differences as inapplicable. When the - // label is contained in the name once hyphens are removed rather than treated - // as word separators, the only difference is hyphenation, so return undefined - // (needs review) instead of a violation. + // 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(removeHyphens(visibleText), removeHyphens(accText)) - ) { - this.data({ messageKey: 'hyphenation' }); + if (isLabelContainedInName(visibleText, accText, { joinNonText: true })) { + this.data({ messageKey: 'punctuation' }); return undefined; } diff --git a/lib/checks/label/label-content-name-mismatch.json b/lib/checks/label/label-content-name-mismatch.json index 072f01418a..a34c998111 100644 --- a/lib/checks/label/label-content-name-mismatch.json +++ b/lib/checks/label/label-content-name-mismatch.json @@ -12,7 +12,7 @@ "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", - "hyphenation": "The visible text and the accessible name differ only in hyphenation" + "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 cbe20d8850..4fe5ed113c 100644 --- a/lib/commons/text/remove-unicode.js +++ b/lib/commons/text/remove-unicode.js @@ -17,23 +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=''] string to substitute for each matched character (e.g. a space to preserve word boundaries) + * @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, 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(), replaceWith); + str = str.replace(emojiRegexText(), replacer); } if (nonBmp) { str = str - .replace(getUnicodeNonBmpRegExp(), replaceWith) - .replace(getSupplementaryPrivateUseRegExp(), replaceWith) - .replace(getCategoryFormatRegExp(), replaceWith); + .replace(getUnicodeNonBmpRegExp(), replacer) + .replace(getSupplementaryPrivateUseRegExp(), replacer) + .replace(getCategoryFormatRegExp(), replacer); } if (punctuations) { - str = str.replace(getPunctuationRegExp(), replaceWith); + 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 e130b32ec7..499c7748f8 100644 --- a/locales/_template.json +++ b/locales/_template.json @@ -820,7 +820,7 @@ "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", - "hyphenation": "The visible text and the accessible name differ only in hyphenation" + "punctuation": "The visible text and the accessible name differ only in punctuation" } }, "multiple-label": { diff --git a/test/checks/label/label-content-name-mismatch.js b/test/checks/label/label-content-name-mismatch.js index 8382da149a..f27105a371 100644 --- a/test/checks/label/label-content-name-mismatch.js +++ b/test/checks/label/label-content-name-mismatch.js @@ -2,6 +2,7 @@ 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; @@ -262,31 +263,33 @@ describe('label-content-name-mismatch tests', () => { assert.isTrue(actual); }); - it('returns undefined (needs review) when the only difference is hyphenation', () => { - const vNode = queryFixture( - 'nonstandard' - ); - const actual = check.evaluate.call( - checkContext, - vNode.actualNode, - options, - vNode - ); - assert.isUndefined(actual); - assert.deepEqual(checkContext._data, { messageKey: 'hyphenation' }); + [ + ['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('returns undefined (needs review) when a hyphenated visible word matches the accessible name', () => { - const vNode = queryFixture( - 'e-mail' - ); - const actual = check.evaluate.call( - checkContext, - vNode.actualNode, - options, - vNode + it('matches the visible label against the accessible name across an open shadow DOM boundary', () => { + const vNode = queryShadowFixture( + '', + 'save' ); - assert.isUndefined(actual); - assert.deepEqual(checkContext._data, { messageKey: 'hyphenation' }); + 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 95b662e35f..b4f0e8d9b9 100644 --- a/test/commons/text/unicode.js +++ b/test/commons/text/unicode.js @@ -294,4 +294,12 @@ describe('text.removeUnicode', () => { }); 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: [],