| @@ -281,11 +281,14 @@ | ||
| 281 | 281 | if ( field.type === 'url' ) { |
| 282 | 282 | maybeAddHttpsToUrl( field ); |
| 283 | 283 | } |
| 284 | 284 | const form = field.closest( 'form' ); |
| 285 | - if ( form && hasClass( form, 'frm_js_validate' ) ) { | |
| 286 | - validateField( field ); | |
| 285 | + if ( ! form ) { | |
| 286 | + return; | |
| 287 | 287 | } |
| 288 | + | |
| 289 | + // Removing stale errors is universal. Adding errors only happens when JS validation is enabled. | |
| 290 | + validateField( field, hasClass( form, 'frm_js_validate' ) ); | |
| 288 | 291 | } |
| 289 | 292 | |
| 290 | 293 | /** |
| 291 | 294 | * @param {HTMLElement} field |
| @@ -300,13 +303,18 @@ | ||
| 300 | 303 | |
| 301 | 304 | /** |
| 302 | 305 | * Validate a field with JS. |
| 303 | 306 | * |
| 307 | + * Removing stale errors is universal. Adding errors only happens when JS validation is enabled. | |
| 308 | + * | |
| 309 | + * @since 6.32 Added the `addErrors` parameter. | |
| 310 | + * | |
| 304 | 311 | * @param {HTMLElement} field |
| 312 | + * @param {boolean} addErrors Whether to add new errors. Defaults to `true`. | |
| 305 | 313 | * |
| 306 | 314 | * @return {void} |
| 307 | 315 | */ |
| 308 | - function validateField( field ) { | |
| 316 | + function validateField( field, addErrors = true ) { | |
| 309 | 317 | let errors; |
| 310 | 318 | let key; |
| 311 | 319 | |
| 312 | 320 | errors = []; |
| @@ -324,13 +332,20 @@ | ||
| 324 | 332 | if ( errors.length < 1 ) { |
| 325 | 333 | validateFieldValue( field, errors, false ); |
| 326 | 334 | } |
| 327 | 335 | |
| 328 | - removeFieldError( fieldContainer ); | |
| 329 | - if ( Object.keys( errors ).length > 0 ) { | |
| 330 | - for ( key in errors ) { | |
| 331 | - addFieldError( fieldContainer, key, errors ); | |
| 336 | + const hasErrors = Object.keys( errors ).length > 0; | |
| 337 | + | |
| 338 | + if ( addErrors ) { | |
| 339 | + removeFieldError( fieldContainer ); | |
| 340 | + if ( hasErrors ) { | |
| 341 | + for ( key in errors ) { | |
| 342 | + addFieldError( fieldContainer, key, errors ); | |
| 343 | + } | |
| 332 | 344 | } |
| 345 | + } else if ( ! hasErrors ) { | |
| 346 | + // JS validation is off, so only remove existing errors once the field passes validation. | |
| 347 | + removeFieldError( fieldContainer ); | |
| 333 | 348 | } |
| 334 | 349 | } |
| 335 | 350 | |
| 336 | 351 | /** |
| @@ -1143,12 +1158,12 @@ | ||
| 1143 | 1158 | return; |
| 1144 | 1159 | } |
| 1145 | 1160 | |
| 1146 | 1161 | container.classList.add( 'frm_blank_field' ); |
| 1147 | - const input = container.querySelector( 'input, select, textarea' ); | |
| 1148 | - const id = getErrorElementId( key, input ); | |
| 1162 | + const inputs = container.querySelectorAll( 'input, select, textarea' ); | |
| 1163 | + const id = getErrorElementId( key, inputs[ 0 ] ); | |
| 1149 | 1164 | |
| 1150 | - let describedBy = input ? input.getAttribute( 'aria-describedby' ) : null; | |
| 1165 | + let describedBy; | |
| 1151 | 1166 | |
| 1152 | 1167 | if ( typeof frmThemeOverride_frmPlaceError === 'function' ) { // eslint-disable-line camelcase |
| 1153 | 1168 | frmThemeOverride_frmPlaceError( key, jsErrors ); |
| 1154 | 1169 | } else { |
| @@ -1159,10 +1174,10 @@ | ||
| 1159 | 1174 | const roleString = frm_js.include_alert_role ? 'role="alert"' : ''; |
| 1160 | 1175 | errorHtml = `<div class="frm_error" ${ roleString } id="${ id }">${ jsErrors[ key ] }</div>`; |
| 1161 | 1176 | } |
| 1162 | 1177 | container.insertAdjacentHTML( 'beforeend', errorHtml ); |
| 1163 | - | |
| 1164 | - if ( input ) { | |
| 1178 | + inputs.forEach( input => { | |
| 1179 | + describedBy = input.getAttribute( 'aria-describedby' ); | |
| 1165 | 1180 | if ( ! describedBy ) { |
| 1166 | 1181 | describedBy = id; |
| 1167 | 1182 | } else if ( ! describedBy.includes( id ) && ! describedBy.includes( 'frm_error_field_' ) ) { |
| 1168 | 1183 | const { errorFirst } = input.dataset; |
| @@ -1172,12 +1187,12 @@ | ||
| 1172 | 1187 | describedBy = `${ id } ${ describedBy }`; |
| 1173 | 1188 | } |
| 1174 | 1189 | } |
| 1175 | 1190 | input.setAttribute( 'aria-describedby', describedBy ); |
| 1176 | - } | |
| 1191 | + } ); | |
| 1177 | 1192 | } |
| 1178 | 1193 | |
| 1179 | - if ( input ) { | |
| 1194 | + inputs.forEach( input => { | |
| 1180 | 1195 | if ( [ 'radio', 'checkbox' ].includes( input.type ) ) { |
| 1181 | 1196 | const group = input.closest( '[role="radiogroup"], [role="group"]' ); |
| 1182 | 1197 | if ( group ) { |
| 1183 | 1198 | group.setAttribute( 'aria-invalid', 'true' ); |
| @@ -1184,9 +1199,9 @@ | ||
| 1184 | 1199 | } |
| 1185 | 1200 | } else { |
| 1186 | 1201 | input.setAttribute( 'aria-invalid', 'true' ); |
| 1187 | 1202 | } |
| 1188 | - } | |
| 1203 | + } ); | |
| 1189 | 1204 | |
| 1190 | 1205 | jQuery( document ).trigger( 'frmAddFieldError', [ jQuery( container ), key, jsErrors ] ); |
| 1191 | 1206 | } |
| 1192 | 1207 | |
| @@ -1218,11 +1233,9 @@ | ||
| 1218 | 1233 | return; |
| 1219 | 1234 | } |
| 1220 | 1235 | |
| 1221 | 1236 | const errorMessage = container.querySelector( '.frm_error' ); |
| 1222 | - const errorId = errorMessage ? errorMessage.id : ''; | |
| 1223 | 1237 | const input = container.querySelector( 'input, select, textarea' ); |
| 1224 | - let describedBy = input ? input.getAttribute( 'aria-describedby' ) : null; | |
| 1225 | 1238 | |
| 1226 | 1239 | container.classList.remove( 'frm_blank_field', 'has-error' ); |
| 1227 | 1240 | |
| 1228 | 1241 | if ( input ) { |
| @@ -1236,20 +1249,35 @@ | ||
| 1236 | 1249 | } |
| 1237 | 1250 | } |
| 1238 | 1251 | |
| 1239 | 1252 | if ( errorMessage ) { |
| 1253 | + removeElementFromInputDescribedBy( errorMessage ); | |
| 1240 | 1254 | errorMessage.remove(); |
| 1241 | 1255 | } |
| 1256 | + } | |
| 1242 | 1257 | |
| 1243 | - if ( input ) { | |
| 1258 | + /** | |
| 1259 | + * Updates the aria-describedby attribute, removing the target element ID. | |
| 1260 | + * | |
| 1261 | + * @since 6.32 | |
| 1262 | + * | |
| 1263 | + * @param {HTMLElement} el The target element that is removed from the aria-describedby data. | |
| 1264 | + * @return {void} | |
| 1265 | + */ | |
| 1266 | + function removeElementFromInputDescribedBy( el ) { | |
| 1267 | + document.querySelectorAll( `[aria-describedby*="${ el.id }"]` ).forEach( input => { | |
| 1268 | + let ariaDescribedBy = input.getAttribute( 'aria-describedby' ).split( ' ' ); | |
| 1269 | + ariaDescribedBy = ariaDescribedBy.filter( value => { | |
| 1270 | + const trimmedValue = value.trim(); | |
| 1271 | + return trimmedValue && trimmedValue !== el.id; | |
| 1272 | + } ); | |
| 1273 | + | |
| 1274 | + if ( ariaDescribedBy.length ) { | |
| 1275 | + input.setAttribute( 'aria-describedby', ariaDescribedBy.join( ' ' ) ); | |
| 1276 | + return; | |
| 1277 | + } | |
| 1244 | 1278 | input.removeAttribute( 'aria-describedby' ); |
| 1245 | - if ( describedBy ) { | |
| 1246 | - describedBy = describedBy.replace( errorId, '' ).trim(); | |
| 1247 | - if ( describedBy ) { | |
| 1248 | - input.setAttribute( 'aria-describedby', describedBy ); | |
| 1249 | - } | |
| 1250 | - } | |
| 1251 | - } | |
| 1279 | + } ); | |
| 1252 | 1280 | } |
| 1253 | 1281 | |
| 1254 | 1282 | function removeAllErrors() { |
| 1255 | 1283 | document.querySelectorAll( '.form-field' ).forEach( field => { |
| @@ -1254,9 +1282,12 @@ | ||
| 1254 | 1282 | function removeAllErrors() { |
| 1255 | 1283 | document.querySelectorAll( '.form-field' ).forEach( field => { |
| 1256 | 1284 | field.classList.remove( 'frm_blank_field', 'has-error' ); |
| 1257 | 1285 | } ); |
| 1258 | - document.querySelectorAll( '.form-field .frm_error' ).forEach( error => error.remove() ); | |
| 1286 | + document.querySelectorAll( '.form-field .frm_error' ).forEach( el => { | |
| 1287 | + removeElementFromInputDescribedBy( el ); | |
| 1288 | + el.remove(); | |
| 1289 | + } ); | |
| 1259 | 1290 | document.querySelectorAll( '.frm_error_style' ).forEach( error => error.remove() ); |
| 1260 | 1291 | } |
| 1261 | 1292 | |
| 1262 | 1293 | /** |