Skip to content

Commit

Permalink
♿ [open-formulieren/open-forms#4546] Ensure that soft required valida…
Browse files Browse the repository at this point in the history
…tion errors and input are linked in an accessible way

Similar to the validation error linking for hard errors, we relate the
content div(s) describing the soft-required validation errors.
  • Loading branch information
sergei-maertens committed Oct 28, 2024
1 parent 9fee7bd commit e729aea
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 11 deletions.
17 changes: 13 additions & 4 deletions src/formio/components/FileField.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {Formio} from 'react-formio';

import {CSRFToken} from 'headers';

import {applyPrefix, setErrorAttributes} from '../utils';
import {applyPrefix, linkToSoftRequiredDisplay, setErrorAttributes} from '../utils';

const addCSRFToken = xhr => {
const csrfTokenValue = CSRFToken.getValue();
Expand Down Expand Up @@ -291,9 +291,13 @@ class FileField extends Formio.Components.components.file {
return super.validatePattern(file, val);
}

setErrorClasses(elements, dirty, hasErrors, hasMessages) {
_getTargetElements() {
const input = this.refs.fileBrowse;
const targetElements = input ? [input] : [];
return input ? [input] : [];
}

setErrorClasses(elements, dirty, hasErrors, hasMessages) {
const targetElements = this._getTargetElements();
setErrorAttributes(targetElements, hasErrors, hasMessages, this.refs.messageContainer.id);
return super.setErrorClasses(targetElements, dirty, hasErrors, hasMessages);
}
Expand All @@ -313,7 +317,12 @@ class FileField extends Formio.Components.components.file {
return false;
}

return super.checkComponentValidity(data, dirty, row, options);
const result = super.checkComponentValidity(data, dirty, row, options);

const targetElements = this._getTargetElements();
linkToSoftRequiredDisplay(targetElements, this);

return result;
}

deleteFile(fileInfo) {
Expand Down
4 changes: 3 additions & 1 deletion src/formio/components/SoftRequiredErrors.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ class SoftRequiredErrors extends FormioContentField {
if (!missingFieldLabels.length) return '';

const missingFieldsMarkup = this.renderTemplate('missingFields', {labels: missingFieldLabels});
return this.interpolate(this.component.html, {
const content = this.interpolate(this.component.html, {
missingFields: missingFieldsMarkup,
});

return `<div id="${this.id}-content">${content}</div>`;
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/formio/components/TextField.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import debounce from 'lodash/debounce';
import {Formio} from 'react-formio';

import {get} from '../../api';
import {setErrorAttributes} from '../utils';
import {linkToSoftRequiredDisplay, setErrorAttributes} from '../utils';
import enableValidationPlugins from '../validators/plugins';

const POSTCODE_REGEX = /^[0-9]{4}\s?[a-zA-Z]{2}$/;
Expand Down Expand Up @@ -35,7 +35,11 @@ class TextField extends Formio.Components.components.textfield {
if (this.component.validate.plugins && this.component.validate.plugins.length) {
updatedOptions.async = true;
}
return super.checkComponentValidity(data, dirty, row, updatedOptions);
const result = super.checkComponentValidity(data, dirty, row, updatedOptions);

linkToSoftRequiredDisplay(this.refs.input, this);

return result;
}

setErrorClasses(elements, dirty, hasErrors, hasMessages) {
Expand Down
58 changes: 58 additions & 0 deletions src/formio/components/TextField.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,61 @@ describe('The mutiple text component', () => {
expect(form.isValid()).toBeTruthy();
});
});

// This is not officially supported yet... but due to the generic implementation it
// works. It is/was intended for file fields only at first.
describe('Textfield with soft required validation', () => {
afterEach(() => {
document.body.innerHTML = '';
});

test('The softRequiredErrors component is linked', async () => {
const user = userEvent.setup({delay: 50});
const FORM = {
type: 'form',
components: [
{
type: 'textfield',
key: 'textfield',
label: 'Text',
validate: {required: false},
openForms: {softRequired: true},
},
{
id: 'softReq123',
type: 'softRequiredErrors',
key: 'softRequiredErrors',
html: `{{ missingFields }}`,
},
],
};
const {form} = await renderForm(FORM);

const input = screen.getByLabelText('Text');

// Trigger validation
await user.type(input, 'foo');
await user.clear(input);
// Lose focus of input
await user.tab({shift: true});

// Input is invalid and should have aria-describedby and aria-invalid
expect(input).not.toHaveClass('is-invalid');
expect(input).toHaveAttribute('aria-describedby');
expect(input).not.toHaveAttribute('aria-invalid', 'true');
expect(form.isValid()).toBeTruthy();

// check that it points to a real div
const expectedDiv = document.getElementById(input.getAttribute('aria-describedby'));
expect(expectedDiv).not.toBeNull();

await user.type(input, 'foo');
await user.tab({shift: true});

// Input is again valid and without aria-describedby and aria-invalid
expect(input).not.toHaveClass('is-invalid');
expect(input).not.toHaveAttribute('aria-describedby');
expect(input).not.toHaveAttribute('aria-invalid');
expect(form.isValid()).toBeTruthy();
});
});
46 changes: 42 additions & 4 deletions src/formio/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import FormioUtils from 'formiojs/utils';

import {PREFIX} from './constants';

/**
Expand All @@ -14,12 +16,13 @@ const escapeHtml = source => {
return pre.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&apos;').replace(/&/g, '&amp;');
};

const getAriaDescriptions = element =>
(element.getAttribute('aria-describedby') || '').split(' ').filter(description => !!description);

const setErrorAttributes = (elements, hasErrors, hasMessages, messageContainerId) => {
// Update the attributes 'aria-invalid' and 'aria-describedby' using hasErrors
elements.forEach(element => {
let ariaDescriptions = (element.getAttribute('aria-describedby') || '')
.split(' ')
.filter(description => !!description);
let ariaDescriptions = getAriaDescriptions(element);

if (hasErrors && hasMessages && !ariaDescriptions.includes(messageContainerId)) {
// The input has an error, but the error message isn't yet part of the ariaDescriptions
Expand All @@ -45,4 +48,39 @@ const setErrorAttributes = (elements, hasErrors, hasMessages, messageContainerId
});
};

export {applyPrefix, escapeHtml, setErrorAttributes};
const linkToSoftRequiredDisplay = (elements, component) => {
// if soft required validation is not enabled, there's nothing to do
if (!component.component.openForms?.softRequired) return;

const isEmpty = component.isEmpty();

const softRequiredIds = [];
FormioUtils.eachComponent(component.root.components, component => {
if (component.type === 'softRequiredErrors') {
const id = `${component.id}-content`;
softRequiredIds.push(id);
}
});

// Update the attribute 'aria-describedby' based on whether the component is empty
elements.forEach(element => {
let ariaDescriptions = getAriaDescriptions(element);

softRequiredIds.forEach(id => {
if (isEmpty && !ariaDescriptions.includes(id)) {
ariaDescriptions.push(id);
}
if (!isEmpty && ariaDescriptions.includes(id)) {
ariaDescriptions = ariaDescriptions.filter(description => description !== id);
}
});

if (ariaDescriptions.length > 0) {
element.setAttribute('aria-describedby', ariaDescriptions.join(' '));
} else {
element.removeAttribute('aria-describedby');
}
});
};

export {applyPrefix, escapeHtml, setErrorAttributes, linkToSoftRequiredDisplay};

0 comments on commit e729aea

Please sign in to comment.