Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/rule-descriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
95 changes: 79 additions & 16 deletions lib/checks/label/label-content-name-mismatch-evaluate.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,36 +5,80 @@ 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
* @returns {Boolean}
*/
function isStringContained(compare, compareWith) {
const curatedCompareWith = curateString(compareWith);
const curatedCompare = curateString(compare);
if (!curatedCompareWith || !curatedCompare) {
function isLabelContainedInName(label, name) {
const labelTokens = curateTokens(label);
const nameTokens = curateTokens(name);
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, 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 curate
* @returns {String}
* @param {String} str given text to tokenize
* @returns {String[]}
*/
function curateString(str) {
const noUnicodeStr = removeUnicode(str, {
function curateTokens(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
punctuations: true,
replaceWith: ' '
});
return sanitize(noUnicodeStr);
return sanitize(separated).split(/\s+/).filter(Boolean);
}
Comment thread
chutchins25 marked this conversation as resolved.
Outdated

/**
* 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;
}

/**
* 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, '');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion — the widened class is untested: every hyphenation test uses ASCII -, so nothing pins \u2010-\u2015\u2212. I confirmed all six dash variants return undefined, but a future narrowing would pass CI silently. One case with aria-label="email" / e–mail would cover it.

Also, U+2212 isn't matched by getPunctuationRegExp — it's in \u2200-\u22FF, i.e. getUnicodeNonBmpRegExp. Both feed the tokenizer so the reasoning holds; "the dashes the tokenizer treats as word separators" would be accurate.

Comment thread
chutchins25 marked this conversation as resolved.
Outdated
}

function labelContentNameMismatchEvaluate(node, options, virtualNode) {
Expand All @@ -59,7 +103,26 @@ 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.
Comment thread
chutchins25 marked this conversation as resolved.
Outdated
//
// 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;
Comment thread
chutchins25 marked this conversation as resolved.
}

return false;
}

export default labelContentNameMismatchEvaluate;
6 changes: 5 additions & 1 deletion lib/checks/label/label-content-name-mismatch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
chutchins25 marked this conversation as resolved.
Outdated
}
}
}
}
13 changes: 7 additions & 6 deletions lib/commons/text/remove-unicode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
chutchins25 marked this conversation as resolved.
Outdated
}

return str;
Expand Down
6 changes: 5 additions & 1 deletion locales/_template.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 7 additions & 6 deletions test/act-rules/visible-label-in-accessible-name-2ee8b8.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
});
65 changes: 65 additions & 0 deletions test/checks/label/label-content-name-mismatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -187,6 +192,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(
'<button id="target" aria-label="save changes">save\nchanges</button>'
);
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(
'<button id="target" aria-label="button label"><img alt="button icon" src="button.png" />button label</button>'
Expand Down Expand Up @@ -224,4 +237,56 @@ 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(
'<button id="target" aria-label="the big red button">big button</button>'
);
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(
'<button id="target" aria-label="go to next page now">next page</button>'
);
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(
'<a id="target" href="#" aria-label="nonstandard">non\u00ADstandard</a>'
);
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(
'<a id="target" href="#" aria-label="non-standard">nonstandard</a>'
);
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(
'<a id="target" href="#" aria-label="email">e-mail</a>'
);
const actual = check.evaluate.call(
checkContext,
vNode.actualNode,
options,
vNode
);
assert.isUndefined(actual);
assert.deepEqual(checkContext._data, { messageKey: 'hyphenation' });
});
Comment thread
chutchins25 marked this conversation as resolved.
Outdated
});
24 changes: 24 additions & 0 deletions test/commons/text/unicode.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,4 +270,28 @@ 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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@
<button id="incomplete16" aria-label="toggle capitalization">aA</button>
<button id="incomplete17" aria-label="CJK character"></button>

<a id="incomplete18" href="#" aria-label="non-standard">nonstandard</a>

<!-- inapplicable -->
<a id="inapplicable1" aria-label="OK">Next</a>
<input id="inapplicable2" type="email" aria-label="E-mail" value="Contact" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
["#incomplete14"],
["#incomplete15"],
["#incomplete16"],
["#incomplete17"]
["#incomplete17"],
["#incomplete18"]
]
}
Loading