Core: Unnecessary aria-describedby (#2410)

* Core: progress on removing aria-describedby from valid fields

* core: aria-describedby

* Core: remove aria-describedby when hiding error

* Core: fix syntax issues, Test: add test for new setting

* Core: Fix bugs in aria-describedby behavior and related tests

* Core: bugfix for labels without id

* Core: don't create a new error element if one exists

* Core: progress on test for group of fields with ariaDescribedbyCleanup

* Core: Groups aria-describedby

* Core: fix aria-describedby not being removed from grouped fields

Ensure that aria-describedby is removed from all members of a group when 
all the known errors are resolved

* Core: Update capitalization

* Demo: Add page for ariaDescribedByCleanup

* Core: add setting to remove aria-describedby from valid fields

Includes additional unit tests and a demo page

* Core: Fix camel case inconsistency, remove stray comment

* Update demo/css/cmxform.css

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Kieran <kieran.brahney@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
eden-jh
2025-10-03 06:46:18 -04:00
committed by GitHub
parent 707623a5ce
commit 6eb2df0da1
7 changed files with 416 additions and 44 deletions

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>jQuery Validation Plugin Demo - ariaDescribedByCleanup set to true</title>
<link rel="stylesheet" href="css/screen.css">
<script src="../lib/jquery.js"></script>
<script src="../dist/jquery.validate.js"></script>
<script>
$.validator.setDefaults({
submitHandler: function() {
alert("submitted!");
}
});
$().ready(function() {
var valid = $("#group-form").validate({
errorElement: 'div',
groups: {
fullName: "first middle last"
},
ariaDescribedByCleanup: true,
rules: {
first: { required: true, minlength: 2 },
middle: {required: true, minlength: 2 },
last: {required: true},
email: { required: true, email: true },
phone: { required: true },
comment: {required: true, maxlength: 300}
}
});
$('button[type="reset"]').on('click',function(){
valid.resetForm();
});
});
</script>
<style>
#group-form {
width: 35rem;
}
.textarea-container {
display: inline-block;
}
.description {
display: block;
}
</style>
</head>
<body>
<h1 id="banner"><a href="https://jqueryvalidation.org/">jQuery Validation Plugin</a> Demo - ariaDescribedByCleanup set to true</h1>
<main id="main">
<form id="group-form" class="cmxform" aria-labelledby="group-example-title" aria-describedby="required-note">
<div class="box">
<h2 id="group-example-title">Example with group</h2>
<div><p id="required-note">Fields marked with * are required</p></div>
<div id="errorlabelcontainer"></div>
<fieldset>
<legend>Name*</legend>
<div class="row">
<div class="col">
<label for="first">First</label>
<input type="text" aria-required="true" id="first" name="first"/>
</div>
<div class="col">
<label for="middle">Middle</label>
<input type="text" aria-required="true" id="middle" name="middle"/>
</div>
<div class="col">
<label for="last">Last</label>
<input type="text" aria-required="true" id="last" name="last"/>
</div>
</div>
</fieldset>
<div class="row">
<label for="email">Email*</label>
<input type="email" id="email" aria-required="true" name="email"/>
</div>
<div class="row">
<label for="phone">Phone*</label>
<input type="text" id="phone" aria-required="true" name="phone"/>
</div>
<div class="row">
<label for="comment">Your comment*</label>
<div class="textarea-container">
<textarea id="comment" name="comment" aria-required="true" aria-describedby="comment-max-length"></textarea>
<span class="description" id="comment-max-length">300 characters maximum</span>
</div>
</div>
<div class="row">
<button>Submit</button>
<button type="reset">Reset</button>
</div>
</div>
</form>
<p><a href="index.html">Back to main page</a></p>
</div>
</main>
</html>

View File

@@ -17,18 +17,36 @@ form.cmxform legend, form.cmxform label {
color: #333;
}
form.cmxform fieldset {
form.cmxform fieldset, form.cmxform .box {
border: none;
border-top: 1px solid #C9DCA6;
background: url(../images/cmxform-fieldset.gif) left bottom repeat-x;
background-color: #F8FDEF;
}
form.cmxform fieldset .col label {
margin-left: 0;
}
form.cmxform fieldset .col {
margin-right: .5rem;
}
form.cmxform fieldset .row .col:last-child {
margin-right: 0;
}
form.cmxform fieldset .row {
display: flex;
form.cmxform fieldset fieldset {
box-sizing: border-box;
}
form.cmxform .box,
form.cmxform fieldset .row,
form.cmxform .box fieldset {
width: 100%;
}
form.cmxform fieldset fieldset, form.cmxform .box fieldset {
background: none;
}
form.cmxform fieldset p, form.cmxform fieldset fieldset {
form.cmxform .box > .row, form.cmxform fieldset p, form.cmxform fieldset fieldset, form.cmxform .box fieldset {
padding: 5px 10px 7px;
background: url(../images/cmxform-divider.gif) left bottom repeat-x;
}
@@ -43,4 +61,4 @@ input { border: 1px solid black; }
input.checkbox { border: none }
input:focus { border: 1px dotted black; }
input.error { border: 1px dotted red; }
form.cmxform .gray * { color: gray; }
form.cmxform .gray * { color: gray; }

View File

@@ -7,7 +7,7 @@ form.cmxform fieldset {
margin-bottom: 10px;
}
form.cmxform legend {
form.cmxform legend, form.cmxform .box .title {
padding: 0 2px;
font-weight: bold;
_margin: 0 -7px; /* IE Win */
@@ -20,29 +20,35 @@ form.cmxform label {
cursor: hand;
}
form.cmxform fieldset p {
form.cmxform fieldset p,
form.cmxform .box p {
list-style: none;
padding: 5px;
margin: 0;
}
form.cmxform fieldset fieldset {
form.cmxform fieldset fieldset,
form.cmxform .box fieldset {
border: none;
margin: 3px 0 0;
}
form.cmxform fieldset fieldset legend {
form.cmxform fieldset fieldset legend, form.cmxform .box fieldset legend {
padding: 0 0 5px;
font-weight: normal;
}
form.cmxform fieldset fieldset label {
display: block;
width: auto;
}
form.cmxform label { width: 100px; } /* Width of labels */
form.cmxform fieldset fieldset label { margin-left: 103px; } /* Width plus 3 (html space) */
form.cmxform fieldset fieldset label,
form.cmxform .box fieldset label {
display: block;
width: auto;
/* Width plus 3 (html space) */
margin-left: 103px;
}
form.cmxform label.error {
margin-left: 103px;
width: 220px;
@@ -52,4 +58,4 @@ form.cmxform input.submit {
margin-left: 103px;
}
/*\*//*/ form.cmxform legend { display: inline-block; } /* IE Mac legend fix */
/*\*//*/ form.cmxform legend { display: inline-block; } /* IE Mac legend fix */

View File

@@ -226,6 +226,8 @@
</li>
<li><a href="semantic-ui/index.html">Using with Semantic-UI</a>
</li>
<li><a href="aria-describedby-cleanup.html">ariaDescribedByCleanup set to true</a>
</li>
</ul>
<h3>Real-world examples</h3>
<ul>

View File

@@ -270,6 +270,7 @@ $.extend( $.validator, {
errorElement: "label",
focusCleanup: false,
focusInvalid: true,
ariaDescribedByCleanup: false,
errorContainer: $( [] ),
errorLabelContainer: $( [] ),
onsubmit: true,
@@ -481,6 +482,8 @@ $.extend( $.validator, {
$.each( this.groups, function( name, testgroup ) {
if ( testgroup === group && name !== checkElement.name ) {
cleanElement = v.validationTargetFor( v.clean( v.findByName( name ) ) );
// Don't want to check fields if a user hasn't gotten to them yet
if ( cleanElement && cleanElement.name in v.invalid ) {
v.currentElements.push( cleanElement );
result = v.check( cleanElement ) && result;
@@ -591,10 +594,76 @@ $.extend( $.validator, {
hideErrors: function() {
this.hideThese( this.toHide );
},
addErrorAriaDescribedBy: function( element, error, updateGroupMembers ) {
updateGroupMembers = ( updateGroupMembers === undefined ) ? false : updateGroupMembers;
var errorID, v, group,
describedBy = $( element ).attr( "aria-describedby" );
errorID = error.attr( "id" );
// Respect existing non-error aria-describedby
if ( !describedBy ) {
describedBy = errorID;
} else if ( !describedBy.match( new RegExp( "\\b" + this.escapeCssMeta( errorID ) + "\\b" ) ) ) {
// Add to end of list if not already present
describedBy += " " + errorID;
}
$( element ).attr( "aria-describedby", describedBy );
if ( updateGroupMembers ) {
// If this element is grouped, then assign to all elements in the same group
group = this.groups[ element.name ];
if ( group ) {
v = this;
$.each( v.groups, function( name, testgroup ) {
if ( testgroup === group ) {
v.addErrorAriaDescribedBy( $( "[name='" + v.escapeCssMeta( name ) + "']", v.currentForm ), error, false );
}
} );
}
}
},
removeErrorAriaDescribedBy: function( element, error ) {
var describedBy = $( element ).attr( "aria-describedby" ),
describedByIds = describedBy.split( " " ),
errorID = error.attr( "id" ),
ind = describedByIds.indexOf( errorID );
if ( ind > -1 ) {
describedByIds.splice( ind, 1 );
}
if ( describedByIds.length ) {
$( element ).attr( "aria-describedby", describedByIds.join( " " ) );
} else {
$( element ).removeAttr( "aria-describedby" );
}
},
hideThese: function( errors ) {
errors.not( this.containers ).text( "" );
this.addWrapper( errors ).hide();
for ( var i = 0; errors[ i ]; i++ ) {
var error = $( errors[ i ] ),
errorID = error.attr( "id" ) ? this.escapeCssMeta( error.attr( "id" ) ) : undefined,
element = ( errorID ) ? this.elements().filter( '[aria-describedby~="' + errorID + '"]' ) : [];
if ( this.settings.ariaDescribedByCleanup && element.length ) {
this.removeErrorAriaDescribedBy( element, error );
}
if ( !error.is( this.containers ) ) {
error.text( "" );
}
this.addWrapper( error ).hide();
}
},
valid: function() {
@@ -900,6 +969,7 @@ $.extend( $.validator, {
defaultShowErrors: function() {
var i, elements, error;
for ( i = 0; this.errorList[ i ]; i++ ) {
error = this.errorList[ i ];
if ( this.settings.highlight ) {
@@ -907,19 +977,23 @@ $.extend( $.validator, {
}
this.showLabel( error.element, error.message );
}
if ( this.errorList.length ) {
this.toShow = this.toShow.add( this.containers );
}
if ( this.settings.success ) {
for ( i = 0; this.successList[ i ]; i++ ) {
this.showLabel( this.successList[ i ] );
}
}
if ( this.settings.unhighlight ) {
for ( i = 0, elements = this.validElements(); elements[ i ]; i++ ) {
this.settings.unhighlight.call( this, elements[ i ], this.settings.errorClass, this.settings.validClass );
}
}
this.toHide = this.toHide.not( this.toShow );
this.hideErrors();
this.addWrapper( this.toShow ).show();
@@ -936,13 +1010,18 @@ $.extend( $.validator, {
},
showLabel: function( element, message ) {
var place, group, errorID, v,
var place,
error = this.errorsFor( element ),
elementID = this.idOrName( element ),
describedBy = $( element ).attr( "aria-describedby" );
if ( error.length ) {
// Non-label error exists but is not currently associated with element via aria-describedby
if ( error.closest( "label[for='" + this.escapeCssMeta( elementID ) + "']" ).length === 0 && ( describedBy === undefined || describedBy.split( " " ).indexOf( error.attr( "id" ) ) === -1 ) ) {
this.addErrorAriaDescribedBy( element, error, true );
}
// Refresh error/success class
error.removeClass( this.settings.validClass ).addClass( this.settings.errorClass );
@@ -987,32 +1066,10 @@ $.extend( $.validator, {
// If the error is a label, then associate using 'for'
error.attr( "for", elementID );
// If the element is not a child of an associated label, then it's necessary
// to explicitly apply aria-describedby
// If the element is not a child of an associated label, then it's necessary
// to explicitly apply aria-describedby
} else if ( error.parents( "label[for='" + this.escapeCssMeta( elementID ) + "']" ).length === 0 ) {
errorID = error.attr( "id" );
// Respect existing non-error aria-describedby
if ( !describedBy ) {
describedBy = errorID;
} else if ( !describedBy.match( new RegExp( "\\b" + this.escapeCssMeta( errorID ) + "\\b" ) ) ) {
// Add to end of list if not already present
describedBy += " " + errorID;
}
$( element ).attr( "aria-describedby", describedBy );
// If this element is grouped, then assign to all elements in the same group
group = this.groups[ element.name ];
if ( group ) {
v = this;
$.each( v.groups, function( name, testgroup ) {
if ( testgroup === group ) {
$( "[name='" + v.escapeCssMeta( name ) + "']", v.currentForm )
.attr( "aria-describedby", error.attr( "id" ) );
}
} );
}
this.addErrorAriaDescribedBy( element, error, true );
}
}
if ( !message && this.settings.success ) {
@@ -1037,6 +1094,9 @@ $.extend( $.validator, {
.replace( /\s+/g, ", #" );
}
// There may be hidden error elements not currently associated via aria-describedby (if ariaDescribedByCleanup is true)
selector = selector + ", #" + name + "-error";
return this
.errors()
.filter( selector );

View File

@@ -347,7 +347,155 @@ QUnit.test( "test existing non-error aria-describedby", function( assert ) {
assert.strictEqual( $( "#testForm17text-description" ).text(), "This is where you enter your data" );
assert.strictEqual( $( "#testForm17text-error" ).text(), "", "Error label is empty for valid field" );
} );
QUnit.test( "test aria-describedby cleanup with existing non-error aria-describedby", function( assert ) {
assert.expect( 13 );
var form = $( "#ariaDescribedByCleanupWithExistingNonError" ),
field = $( "#testCleanupExistingNonErrortext" ),
errorID = "testCleanupExistingNonErrortext-error",
descriptionID = "testCleanupExistingNonErrortext-description";
assert.equal( field.attr( "aria-describedby" ), descriptionID );
// First test an invalid value
form.validate( { errorElement: "span", ariaDescribedByCleanup: true } );
assert.ok( !field.valid() );
assert.equal( ( field.attr( "aria-describedby" ).split( " " ).indexOf( errorID ) > -1 && field.attr( "aria-describedby" ).split( " " ).indexOf( descriptionID ) > -1 ), true );
assert.hasError( field, "required" );
var errorElement = form.validate().errorsFor( field[ 0 ] );
assert.equal( errorElement.attr( "id" ), errorID );
// Then make it valid again to ensure that the aria-describedby relationship is restored
field.val( "foo" );
assert.ok( field.valid() );
assert.noErrorFor( field );
assert.equal( field.attr( "aria-describedby" ), descriptionID );
assert.strictEqual( true, errorElement.is( ":hidden" ) );
// Then make it invalid again
field.val( "" ).trigger( "keyup" );
assert.ok( !field.valid() );
assert.hasError( field, "required" );
// Make sure there's not more than one error
assert.equal( $( "[id=" + errorID + "]" ).length, 1 );
assert.equal( ( field.attr( "aria-describedby" ).split( " " ).indexOf( errorID ) > -1 && field.attr( "aria-describedby" ).split( " " ).indexOf( descriptionID ) > -1 ), true );
} );
QUnit.test( "test aria-describedby cleanup when field becomes valid", function( assert ) {
assert.expect( 16 );
var form = $( "#ariaDescribedByCleanup" ),
field = $( "#ariaDescribedByCleanupText" ),
errorID = "ariaDescribedByCleanupText-error";
// First test an invalid value
form.validate( { errorElement: "span", ariaDescribedByCleanup: true } );
assert.ok( !field.valid() );
assert.equal( field.attr( "aria-describedby" ), "ariaDescribedByCleanupText-error" );
assert.hasError( field, "required" );
var errorElement = form.validate().errorsFor( field[ 0 ] );
assert.equal( field.attr( "aria-describedby" ), errorID );
assert.equal( errorElement.attr( "id" ), errorID );
// Then make it valid again to ensure that the aria-describedby relationship is restored
field.val( "foo" );
assert.ok( field.valid() );
assert.noErrorFor( field );
assert.notOk( field.attr( "aria-describedby" ) );
assert.strictEqual( true, errorElement.is( ":hidden" ) );
// Then make it invalid again
field.val( "" ).trigger( "keyup" );
assert.ok( !field.valid() );
assert.equal( field.attr( "aria-describedby" ), "ariaDescribedByCleanupText-error" );
assert.hasError( field, "required" );
errorElement = form.validate().errorsFor( field[ 0 ] );
assert.ok( errorElement );
// Make sure there's not more than one error
assert.equal( $( "[id=" + errorID + "]" ).length, 1 );
assert.ok( field.attr( "aria-describedby" ) );
assert.equal( field.attr( "aria-describedby" ), errorID );
} );
QUnit.test( "test aria-describedby cleanup on group", function( assert ) {
assert.expect( 34 );
var form = $( "#ariaDescribedByCleanupGroup" ),
firstID = "ariaDescribedByCleanupGroupFirst",
first = $( "#" + firstID ),
middleID = "ariaDescribedByCleanupGroupMiddle",
middle = $( "#" + middleID ),
lastID = "ariaDescribedByCleanupGroupLast",
last = $( "#" + lastID ),
emailID = "ariaDescribedByCleanupGroupEmail",
email = $( "#" + emailID ),
groupName = "ariaDescribedByCleanupGroupName",
groupOptions = { };
groupOptions[ groupName ] = firstID + " " + middleID + " " + lastID;
// First test an invalid value
form.validate( { errorElement: "span", ariaDescribedByCleanup: true, groups: groupOptions } );
form.trigger( "submit" );
assert.equal( first.attr( "aria-describedby" ), groupName + "-error" );
assert.hasError( first, "required" );
assert.hasError( middle, "required" );
assert.hasError( last, "required" );
var errorElement = form.validate().errorsFor( first[ 0 ] );
// Previous behavior was for error to apply to all group members. It still does that, but now it removes aria-describedby from each individual field (or at least I think that's what's happening because when first name has an error, middle and last are valid and don't have aria-describedby)
assert.equal( errorElement.attr( "id" ), groupName + "-error" );
assert.equal( middle.attr( "aria-describedby" ), groupName + "-error" );
assert.equal( last.attr( "aria-describedby" ), groupName + "-error" );
// Check email field
assert.hasError( email, "required" );
assert.equal( email.attr( "aria-describedby" ), emailID + "-error" );
// Then make it valid again to ensure that the aria-describedby relationship is restored
first.val( "Person" );
middle.val( "Syntax" );
last.val( "Personname" );
email.val( "aa" );
form.trigger( "submit" );
assert.hasError( email, "email" );
assert.equal( email.attr( "aria-describedby" ), emailID + "-error" );
var emailError = form.validate().errorsFor( email[ 0 ] );
assert.equal( emailError.attr( "id" ), email.attr( "aria-describedby" ) );
assert.ok( first.valid() );
assert.noErrorFor( first );
assert.notOk( first.attr( "aria-describedby" ) );
assert.noErrorFor( middle );
assert.notOk( middle.attr( "aria-describedby" ) );
assert.noErrorFor( last );
assert.notOk( last.attr( "aria-describedby" ) );
assert.strictEqual( true, errorElement.is( ":hidden" ) );
// Then make it invalid again
first.val( "" ).trigger( "keyup" );
assert.hasError( first, "required" );
assert.equal( first.attr( "aria-describedby" ), groupName + "-error" );
assert.equal( errorElement.attr( "id" ), groupName + "-error" );
assert.equal( middle.attr( "aria-describedby" ), groupName + "-error" );
assert.equal( last.attr( "aria-describedby" ), groupName + "-error" );
assert.ok( !first.valid() );
// Make sure there's not more than one error
assert.equal( $( "[id=" + groupName + "-error]" ).length, 1 );
assert.equal( $( "[id=" + emailID + "-error]" ).length, 1 );
email.val( "test@test.com" ).trigger( "keyup" );
first.val( "Person" ).trigger( "keyup" );
assert.noErrorFor( first );
assert.noErrorFor( email );
assert.notOk( email.attr( "aria-describedby" ) );
assert.notOk( first.attr( "aria-describedby" ) );
assert.notOk( middle.attr( "aria-describedby" ) );
assert.notOk( last.attr( "aria-describedby" ) );
} );
QUnit.test( "test pre-assigned non-error aria-describedby", function( assert ) {
assert.expect( 7 );
var form = $( "#testForm17" ),

View File

@@ -188,6 +188,34 @@
<input name="testForm17text" id="testForm17text" data-rule-required="true" data-msg="required" aria-describedby="testForm17text-description">
<span id="testForm17text-description">This is where you enter your data</span>
</form>
<form id="ariaDescribedByCleanupWithExistingNonError">
<!-- test existing non-error aria-describedby with aria-describedby cleanup on valid fields -->
<label for="testCleanupExistingNonErrortext">My Label</label>
<input name="testCleanupExistingNonErrortext" id="testCleanupExistingNonErrortext" data-rule-required="true" data-msg="required" aria-describedby="testCleanupExistingNonErrortext-description">
<span id="testCleanupExistingNonErrortext-description">This is where you enter your data</span>
</form>
<form id="ariaDescribedByCleanup">
<!-- Test aria-describedby cleanup on valid fields -->
<label for="ariaDescribedByCleanupText">My label</label>
<input name="ariaDescribedByCleanupText" id="ariaDescribedByCleanupText" data-rule-required="true" data-msg="required"/>
</form>
<form id="ariaDescribedByCleanupGroup">
<!-- Test aria-describedby cleanup on valid fields in a group -->
<fieldset>
<legend>
Name
</legend>
<label for="ariaDescribedByCleanupGroupFirst">First</label>
<input name="ariaDescribedByCleanupGroupFirst" id="ariaDescribedByCleanupGroupFirst" data-rule-required="true" data-msg="required"/>
<label for="ariaDescribedByCleanupGroupMiddle">Middle</label>
<input name="ariaDescribedByCleanupGroupMiddle" id="ariaDescribedByCleanupGroupMiddle" data-rule-required="true" data-msg="required"/>
<label for="ariaDescribedByCleanupGroupLast">Last</label>
<input name="ariaDescribedByCleanupGroupLast" id="ariaDescribedByCleanupGroupLast" data-rule-required="true" data-msg="required"/>
</fieldset>
<label for="ariaDescribedByCleanupGroupEmail">Email</label>
<input name="ariaDescribedByCleanupGroupEmail" id="ariaDescribedByCleanupGroupEmail" type="email" data-rule-email="true" data-rule-required="true"/>
<button>Submit</button>
</form>
<form id="testForm18">
<!-- test id/name containing brackets -->
<input name="testForm18[text]" id="testForm18[text]" required>
@@ -454,12 +482,12 @@
<input name="year"/>
<button name="submitForm27" value="someValue" type="submit"><span>Submit</span></button>
</form>
<form id="cnhFormTest">
<input id="cnhnumber" name="cnhnumber" required>
<button name="submitFormCnh" value="submitFormCnh" type="submit"><span>Submit</span></button>
</form>
<form id="_contenteditableForm">
<div name="first_name" id="first_name" contenteditable placeholder="First Name"></div>
<br>