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"]
]
}