diff --git a/src/components/ProgressIndicator/ProgressIndicatorItem.js b/src/components/ProgressIndicator/ProgressIndicatorItem.js index 27abf1bbd..2e6d4c1d4 100644 --- a/src/components/ProgressIndicator/ProgressIndicatorItem.js +++ b/src/components/ProgressIndicator/ProgressIndicatorItem.js @@ -46,6 +46,7 @@ export const ProgressIndicatorItem = ({ placeholder={!canNavigateTo} modifiers={canNavigateTo ? getLinkModifiers(isActive) : []} aria-label={label} + aria-current={isActive ? 'step' : undefined} > @@ -40,10 +41,11 @@ const Checkbox = ({ invalid={invalid} required={isRequired} appearance="custom" + aria-describedby={errorMessageId} {...inputProps} /> {description} - {touched && } + {touched && } ); }; diff --git a/src/components/forms/DateField/DateField.js b/src/components/forms/DateField/DateField.js index 47342d467..e6e7f8ade 100644 --- a/src/components/forms/DateField/DateField.js +++ b/src/components/forms/DateField/DateField.js @@ -64,6 +64,7 @@ const DateField = ({ break; } } + const errorMessageId = invalid ? `${id}-error-message` : undefined; return ( @@ -76,11 +77,12 @@ const DateField = ({ disabled={disabled} invalid={invalid} extraOnChange={onChange} + aria-describedby={errorMessageId} {...fieldProps} {...props} /> {description} - {touched && } + {touched && } ); }; diff --git a/src/components/forms/EmailField/EmailField.js b/src/components/forms/EmailField/EmailField.js index 0973e0923..6fbecd648 100644 --- a/src/components/forms/EmailField/EmailField.js +++ b/src/components/forms/EmailField/EmailField.js @@ -11,6 +11,7 @@ EmailField.propTypes = { description: PropTypes.node, id: PropTypes.string, disabled: PropTypes.bool, + autocomplete: PropTypes.oneOf(['email']), }; export default EmailField; diff --git a/src/components/forms/NumberField/NumberField.js b/src/components/forms/NumberField/NumberField.js index 879c1a29b..f462c8938 100644 --- a/src/components/forms/NumberField/NumberField.js +++ b/src/components/forms/NumberField/NumberField.js @@ -46,6 +46,7 @@ const NumberField = ({ thousandSeparator, decimalScale: isInteger ? undefined : 2, }; + const errorMessageId = invalid ? `${id}-error-message` : undefined; return ( @@ -66,11 +67,12 @@ const NumberField = ({ type={useNumberType ? 'number' : 'text'} customInput={Textbox} readOnly={readOnly} + aria-describedby={errorMessageId} {...separatorProps} /> {description} - {touched && } + {touched && } ); }; diff --git a/src/components/forms/RadioField/RadioField.js b/src/components/forms/RadioField/RadioField.js index 8f53a72f1..3614fb49f 100644 --- a/src/components/forms/RadioField/RadioField.js +++ b/src/components/forms/RadioField/RadioField.js @@ -35,6 +35,7 @@ export const RadioField = ({ const {error, touched} = getFieldMeta(name); const invalid = touched && !!error; const descriptionid = `${id}-description`; + const errorMessageId = invalid ? `${id}-error-message` : undefined; return (
@@ -74,7 +76,7 @@ export const RadioField = ({ ))} {description} - {touched && } + {touched && }
); }; diff --git a/src/components/forms/SelectField/SelectField.js b/src/components/forms/SelectField/SelectField.js index 8f6fbb63f..4d68e82cd 100644 --- a/src/components/forms/SelectField/SelectField.js +++ b/src/components/forms/SelectField/SelectField.js @@ -32,6 +32,7 @@ const SelectField = ({ const {setValue, setTouched} = getFieldHelpers(name); const invalid = touched && !!error; + const errorMessageId = invalid ? `${id}-error-message` : undefined; // map the formik value back to the value object for react-select let value = undefined; @@ -135,9 +136,10 @@ const SelectField = ({ }} value={value} onBlur={() => setTouched(true)} + aria-describedby={errorMessageId} /> {description} - {touched && } + {touched && } ); }; diff --git a/src/components/forms/TextField/TextField.js b/src/components/forms/TextField/TextField.js index 6a0aaf248..1afad4daa 100644 --- a/src/components/forms/TextField/TextField.js +++ b/src/components/forms/TextField/TextField.js @@ -20,6 +20,7 @@ export const TextField = ({ const {error, touched} = getFieldMeta(name); const invalid = touched && !!error; + const errorMessageId = invalid ? `${id}-error-message` : undefined; return ( ); }; diff --git a/src/components/forms/ValidationErrors.js b/src/components/forms/ValidationErrors.js index 347f99945..08d98d928 100644 --- a/src/components/forms/ValidationErrors.js +++ b/src/components/forms/ValidationErrors.js @@ -2,10 +2,14 @@ import {FormFieldDescription} from '@utrecht/component-library-react'; import PropTypes from 'prop-types'; import React from 'react'; -const ValidationErrors = ({error = ''}) => { +const ValidationErrors = ({error = '', id}) => { if (!error) return null; return ( - + {error} ); @@ -13,6 +17,7 @@ const ValidationErrors = ({error = ''}) => { ValidationErrors.propTypes = { error: PropTypes.string, + id: PropTypes.string, }; export default ValidationErrors; diff --git a/src/components/modals/FormStepSaveModal.js b/src/components/modals/FormStepSaveModal.js index c0ec85119..254d52dd2 100644 --- a/src/components/modals/FormStepSaveModal.js +++ b/src/components/modals/FormStepSaveModal.js @@ -209,6 +209,7 @@ const FormStepSaveModal = ({ defaultMessage="The email address where you will receive the resume link." /> } + autocomplete="email" /> diff --git a/src/formio/components/Checkbox.js b/src/formio/components/Checkbox.js index 4ed705324..5ef53908d 100644 --- a/src/formio/components/Checkbox.js +++ b/src/formio/components/Checkbox.js @@ -1,5 +1,6 @@ import {Formio} from 'react-formio'; +import {setErrorAttributes} from '../utils'; import './Checkbox.scss'; /** @@ -17,6 +18,11 @@ class Checkbox extends Formio.Components.components.checkbox { ].join(' '); return info; } + + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } } export default Checkbox; diff --git a/src/formio/components/Checkbox.spec.js b/src/formio/components/Checkbox.spec.js new file mode 100644 index 000000000..437e89b12 --- /dev/null +++ b/src/formio/components/Checkbox.spec.js @@ -0,0 +1,75 @@ +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const selectboxesForm = { + type: 'form', + components: [ + { + key: 'checkbox', + type: 'checkbox', + label: 'Checkbox', + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async () => { + let formJSON = _.cloneDeep(selectboxesForm); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The checkbox component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Checkbox component required and checked', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const checkbox = screen.getByLabelText('Checkbox'); + + expect(checkbox).toBeVisible(); + + await user.click(checkbox); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Checkbox component required without being checked', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const checkbox = screen.getByLabelText('Checkbox'); + + // Check and uncheck the checkbox to trigger the validation + await user.click(checkbox); + await user.click(checkbox); + + // All selectboxes are marked as invalid and have aria-describedby and aria-invalid + expect(checkbox).toHaveClass('is-invalid'); + expect(checkbox).toHaveAttribute('aria-describedby'); + expect(checkbox).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.click(checkbox); + + // All checkboxes are again marked as valid and without aria-describedby and aria-invalid + expect(checkbox).not.toHaveClass('is-invalid'); + expect(checkbox).not.toHaveAttribute('aria-describedby'); + expect(checkbox).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/Currency.js b/src/formio/components/Currency.js index 8ada410e5..eda79ad6c 100644 --- a/src/formio/components/Currency.js +++ b/src/formio/components/Currency.js @@ -3,6 +3,8 @@ import FormioUtils from 'formiojs/utils'; import _, {set} from 'lodash'; import {Formio} from 'react-formio'; +import {setErrorAttributes} from '../utils'; + /** * Extend the default text field to modify it to our needs. */ @@ -79,6 +81,11 @@ class Currency extends Formio.Components.components.currency { return info; } + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } + // Issue OF#1351 // Taken from Formio https://github.com/formio/formio.js/blob/v4.13.13/src/components/currency/Currency.js#L65 // Modified for the case where negative currencies are allowed. diff --git a/src/formio/components/Currency.spec.js b/src/formio/components/Currency.spec.js new file mode 100644 index 000000000..e78e5e09c --- /dev/null +++ b/src/formio/components/Currency.spec.js @@ -0,0 +1,77 @@ +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const currencyForm = { + type: 'form', + components: [ + { + key: 'currency', + type: 'currency', + label: 'Currency', + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async () => { + let formJSON = _.cloneDeep(currencyForm); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The currency component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single currency component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const input = screen.getByLabelText('Currency'); + + expect(input).toBeVisible(); + + await user.type(input, '6'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Single currency component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const input = screen.getByLabelText('Currency'); + + // Trigger validation + await user.type(input, '6'); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '6'); + 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(); + }); +}); diff --git a/src/formio/components/DateField.js b/src/formio/components/DateField.js index 0732fac2a..0a4b14bc7 100644 --- a/src/formio/components/DateField.js +++ b/src/formio/components/DateField.js @@ -2,6 +2,8 @@ import {Formio} from 'react-formio'; import {MinMaxDateValidator} from 'formio/validators/minMaxDateAndDatetimeValidator'; +import {setErrorAttributes} from '../utils'; + const DateTimeField = Formio.Components.components.datetime; const extractDate = value => { @@ -40,6 +42,18 @@ class DateField extends DateTimeField { return info; } + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + const inputClone = this.element.querySelector('input:not([type="hidden"])'); + const targetElements = inputClone ? [inputClone] : []; + + // setErrorAttributes cannot be done for a `multiple` component + // https://github.com/open-formulieren/open-forms-sdk/pull/717#issuecomment-2405060364 + if (!this.component.multiple) { + setErrorAttributes(targetElements, hasErrors, hasMessages, this.refs.messageContainer.id); + } + return super.setErrorClasses(targetElements, dirty, hasErrors, hasMessages); + } + beforeSubmit() { // The field itself should prevent any invalid dates from being passed in // so we are not checking that here diff --git a/src/formio/components/DateField.spec.js b/src/formio/components/DateField.spec.js new file mode 100644 index 000000000..23ce22e2d --- /dev/null +++ b/src/formio/components/DateField.spec.js @@ -0,0 +1,229 @@ +import {waitFor} from '@storybook/test'; +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import 'flatpickr'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; +import {sleep} from 'utils'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const dateForm = { + type: 'form', + components: [ + { + key: 'date', + type: 'date', + label: 'Date', + format: 'dd-MM-yyyy', + validate: { + required: true, + }, + }, + ], +}; + +const multipleDateForm = { + type: 'form', + components: [ + { + key: 'date-multiple', + type: 'date', + label: 'Multiple date', + format: 'dd-MM-yyyy', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +const waitForFlatpickr = async node => { + let calendarNode; + for (let i = 0; i < 20; i++) { + calendarNode = node.querySelector('.flatpickr-calendar'); + if (calendarNode !== null) return; + await sleep(100); + } +}; + +describe('The date component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single date component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(dateForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + expect(input).toBeVisible(); + + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + + expect(form.isValid()).toBeTruthy(); + }, 10000); + + test('Single date component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(dateForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + expect(input).toBeVisible(); + + // Trigger validation + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + + // Input is again valid and without aria-describedby and aria-invalid + await waitFor(async () => { + expect(input).not.toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); +}); + +describe('The multiple date component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple date component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + expect(input).toBeVisible(); + + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + + expect(form.isValid()).toBeTruthy(); + }, 10000); + + test('Multiple date component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + // Trigger validation + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + + // The field is again valid + + await waitFor(async () => { + expect(input).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); + + test('Multiple date without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, '16-08-2024'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }, 10000); + + test('Multiple date with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateForm); + await waitForFlatpickr(container); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], '08-08-2024 14:45'); + await user.type(inputs[1], '08-08-2024 14:45'); + await user.click(container); + + // The Both inputs are valid + await waitFor(async () => { + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + + await user.clear(inputs[0]); + await user.click(container); + + // Only the invalid input is marked as invalid + await waitFor(async () => { + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + }); + + await user.type(inputs[0], '16-08-2024'); + await user.tab({shift: true}); + + // Both inputs are again valid + await waitFor(async () => { + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); +}); diff --git a/src/formio/components/DateTimeField.js b/src/formio/components/DateTimeField.js index 6b2abacc0..5853a84ff 100644 --- a/src/formio/components/DateTimeField.js +++ b/src/formio/components/DateTimeField.js @@ -2,6 +2,8 @@ import {Formio} from 'react-formio'; import {MinMaxDatetimeValidator} from 'formio/validators/minMaxDateAndDatetimeValidator'; +import {setErrorAttributes} from '../utils'; + const DateTimeFormio = Formio.Components.components.datetime; class DateTimeField extends DateTimeFormio { @@ -27,6 +29,18 @@ class DateTimeField extends DateTimeFormio { return info; } + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + const inputClone = this.element.querySelector('input:not([type="hidden"])'); + const targetElements = inputClone ? [inputClone] : []; + + // setErrorAttributes cannot be done for a `multiple` component + // https://github.com/open-formulieren/open-forms-sdk/pull/717#issuecomment-2405060364 + if (!this.component.multiple) { + setErrorAttributes(targetElements, hasErrors, hasMessages, this.refs.messageContainer.id); + } + return super.setErrorClasses(targetElements, dirty, hasErrors, hasMessages); + } + get suffix() { // Don't show an icon return null; diff --git a/src/formio/components/DateTimeField.spec.js b/src/formio/components/DateTimeField.spec.js new file mode 100644 index 000000000..aecf39041 --- /dev/null +++ b/src/formio/components/DateTimeField.spec.js @@ -0,0 +1,229 @@ +import {waitFor} from '@storybook/test'; +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import 'flatpickr'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; +import {sleep} from 'utils'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const dateTimeForm = { + type: 'form', + components: [ + { + key: 'dateTime', + type: 'datetime', + label: 'Datetime', + format: 'dd-MM-yyyy HH:mm', + validate: { + required: true, + }, + }, + ], +}; + +const multipleDateTimeForm = { + type: 'form', + components: [ + { + key: 'datetime-multiple', + type: 'datetime', + label: 'Multiple datetime', + format: 'dd-MM-yyyy HH:mm', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +const waitForFlatpickr = async node => { + let calendarNode; + for (let i = 0; i < 20; i++) { + calendarNode = node.querySelector('.flatpickr-calendar'); + if (calendarNode !== null) return; + await sleep(100); + } +}; + +describe('The datetime component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single datetime component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(dateTimeForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + expect(input).toBeVisible(); + + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + + expect(form.isValid()).toBeTruthy(); + }, 10000); + + test('Single datetime component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(dateTimeForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + expect(input).toBeVisible(); + + // Trigger validation + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + + // Input is again valid and without aria-describedby and aria-invalid + await waitFor(async () => { + expect(input).not.toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); +}); + +describe('The multiple datetime component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple datetime component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateTimeForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + expect(input).toBeVisible(); + + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + + expect(form.isValid()).toBeTruthy(); + }, 10000); + + test('Multiple datetime component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateTimeForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + + // Trigger validation + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + + // The field is again valid + + await waitFor(async () => { + expect(input).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); + + test('Multiple datetime without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateTimeForm); + await waitForFlatpickr(container); + + const input = screen.getByRole('textbox'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, '16-08-2024 14:45'); + await user.tab({shift: true}); + await user.clear(input); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }, 10000); + + test('Multiple datetime with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form, container} = await renderForm(multipleDateTimeForm); + await waitForFlatpickr(container); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], '08-08-2024 14:45'); + await user.type(inputs[1], '08-08-2024 14:45'); + await user.click(container); + + // The Both inputs are valid + await waitFor(async () => { + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + + await user.clear(inputs[0]); + await user.click(container); + + // Only the invalid input is marked as invalid + await waitFor(async () => { + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + }); + + await user.type(inputs[0], '16-08-2024 14:45'); + await user.tab({shift: true}); + + // Both inputs are again valid + await waitFor(async () => { + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + }, 10000); +}); diff --git a/src/formio/components/Email.js b/src/formio/components/Email.js index cf921c8a7..3f1629fb0 100644 --- a/src/formio/components/Email.js +++ b/src/formio/components/Email.js @@ -1,5 +1,7 @@ import {Formio} from 'react-formio'; +import {setErrorAttributes} from '../utils'; + /** * Extend the default email field to modify it to our needs. */ @@ -16,6 +18,15 @@ class Email extends Formio.Components.components.email { return info; } + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + // setErrorAttributes cannot be done for a `multiple` component + // https://github.com/open-formulieren/open-forms-sdk/pull/717#issuecomment-2405060364 + if (!this.component.multiple) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + } + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } + restoreCaretPosition() { if (!this.root?.currentSelection || !this.refs.input?.length) return; diff --git a/src/formio/components/FileField.js b/src/formio/components/FileField.js index d104c8269..906d341a9 100644 --- a/src/formio/components/FileField.js +++ b/src/formio/components/FileField.js @@ -4,7 +4,7 @@ import {Formio} from 'react-formio'; import {CSRFToken} from 'headers'; -import {applyPrefix} from '../utils'; +import {applyPrefix, setErrorAttributes} from '../utils'; const addCSRFToken = xhr => { const csrfTokenValue = CSRFToken.getValue(); @@ -291,6 +291,13 @@ class FileField extends Formio.Components.components.file { return super.validatePattern(file, val); } + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + const input = this.refs.fileBrowse; + const targetElements = input ? [input] : []; + setErrorAttributes(targetElements, hasErrors, hasMessages, this.refs.messageContainer.id); + return super.setErrorClasses(targetElements, dirty, hasErrors, hasMessages); + } + checkComponentValidity(data, dirty, row, options = {}) { if (this.loading) { // This prevents the FormStep from being submitted before the file upload is finished. diff --git a/src/formio/components/IBANField.js b/src/formio/components/IBANField.js index 0b583d064..166b8eeae 100644 --- a/src/formio/components/IBANField.js +++ b/src/formio/components/IBANField.js @@ -1,6 +1,8 @@ import {electronicFormatIBAN, isValidIBAN} from 'ibantools'; import {Formio} from 'react-formio'; +import {setErrorAttributes} from '../utils'; + const TextField = Formio.Components.components.textfield; const IbanValidator = { @@ -49,4 +51,13 @@ export default class IBANField extends TextField { ].join(' '); return info; } + + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + // setErrorAttributes cannot be done for a `multiple` component + // https://github.com/open-formulieren/open-forms-sdk/pull/717#issuecomment-2405060364 + if (!this.component.multiple) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + } + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } } diff --git a/src/formio/components/Number.js b/src/formio/components/Number.js index 878dea63e..2c56b0638 100644 --- a/src/formio/components/Number.js +++ b/src/formio/components/Number.js @@ -2,6 +2,7 @@ import {maskInput} from '@formio/vanilla-text-mask'; import {set} from 'lodash'; import {Formio} from 'react-formio'; +import {setErrorAttributes} from '../utils'; import enableValidationPlugins from '../validators/plugins'; /** @@ -32,6 +33,15 @@ class Number extends Formio.Components.components.number { return super.checkComponentValidity(data, dirty, row, updatedOptions); } + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + // setErrorAttributes cannot be done for a `multiple` component + // https://github.com/open-formulieren/open-forms-sdk/pull/717#issuecomment-2405060364 + if (!this.component.multiple) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + } + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } + // Issue OF#1351 // Taken from Formio https://github.com/formio/formio.js/blob/v4.13.13/src/components/number/Number.js#L112 // Modified for the case where negative numbers are allowed. diff --git a/src/formio/components/Number.spec.js b/src/formio/components/Number.spec.js new file mode 100644 index 000000000..93842c76c --- /dev/null +++ b/src/formio/components/Number.spec.js @@ -0,0 +1,192 @@ +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const numberForm = { + type: 'form', + components: [ + { + key: 'number', + type: 'number', + label: 'Number', + validate: { + required: true, + }, + }, + ], +}; + +const multipleNumberForm = { + type: 'form', + components: [ + { + key: 'number', + type: 'number', + label: 'Multiple number', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The number component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single number component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(numberForm); + + const input = screen.getByLabelText('Number'); + + expect(input).toBeVisible(); + + await user.type(input, '6'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Single number component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(numberForm); + + const input = screen.getByLabelText('Number'); + + // Trigger validation + await user.type(input, '6'); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '6'); + 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(); + }); +}); + +describe('The multiple number component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple number component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleNumberForm); + + const input = screen.getByLabelText('Multiple number'); + + expect(input).toBeVisible(); + + await user.type(input, '6'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple number component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleNumberForm); + + const input = screen.getByLabelText('Multiple number'); + + // Trigger validation + await user.type(input, '6'); + await user.clear(input); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, '6'); + await user.tab({shift: true}); + + // The field is again valid + expect(input).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple number without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleNumberForm); + + const input = screen.getByLabelText('Multiple number'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, '6'); + await user.clear(input); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple number with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleNumberForm); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], '6'); + await user.type(inputs[1], '12'); + await user.tab({shift: true}); + + // The Both inputs are valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + + await user.clear(inputs[0]); + await user.tab({shift: true}); + + // Both inputs are now marked as invalid + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(inputs[0], '3'); + await user.tab({shift: true}); + + // Both inputs are again valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/Password.js b/src/formio/components/Password.js index db3e6135b..5b0fd4796 100644 --- a/src/formio/components/Password.js +++ b/src/formio/components/Password.js @@ -1,5 +1,7 @@ import {Formio} from 'react-formio'; +import {setErrorAttributes} from '../utils'; + /** * Extend the default password field to modify it to our needs. * @@ -16,6 +18,15 @@ class Password extends Formio.Components.components.password { ].join(' '); return info; } + + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + // setErrorAttributes cannot be done for a `multiple` component + // https://github.com/open-formulieren/open-forms-sdk/pull/717#issuecomment-2405060364 + if (!this.component.multiple) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + } + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } } export default Password; diff --git a/src/formio/components/Password.spec.js b/src/formio/components/Password.spec.js new file mode 100644 index 000000000..f249fd7ba --- /dev/null +++ b/src/formio/components/Password.spec.js @@ -0,0 +1,194 @@ +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getAllChildInputs, getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const passwordForm = { + type: 'form', + components: [ + { + key: 'password', + type: 'password', + label: 'Password', + validate: { + required: true, + }, + }, + ], +}; + +const multiplePasswordForm = { + type: 'form', + components: [ + { + key: 'password', + type: 'password', + label: 'Multiple password', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The password component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single password component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(passwordForm); + + const input = screen.getByLabelText('Password'); + + expect(input).toBeVisible(); + + await user.type(input, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Single password component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(passwordForm); + + const input = screen.getByLabelText('Password'); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + 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(); + }); +}); + +describe('The multiple password component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple password component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multiplePasswordForm); + + const input = screen.getByLabelText('Multiple password'); + + expect(input).toBeVisible(); + + await user.type(input, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple password component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multiplePasswordForm); + + const input = screen.getByLabelText('Multiple password'); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, 'foo'); + await user.tab({shift: true}); + + // The field is again valid + expect(input).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple password without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multiplePasswordForm); + + const input = screen.getByLabelText('Multiple password'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple password with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multiplePasswordForm); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + // Password inputs cannot be found using `getByRole` https://github.com/testing-library/dom-testing-library/issues/1128#issuecomment-1125662009 + const component = getComponentNode(screen.getByLabelText('Multiple password')); + const inputs = getAllChildInputs(component); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], 'foo'); + await user.type(inputs[1], 'bar'); + await user.tab({shift: true}); + + // The Both inputs are valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + + await user.clear(inputs[0]); + await user.tab({shift: true}); + + // Both inputs are now marked as invalid + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(inputs[0], 'foo bar'); + await user.tab({shift: true}); + + // Both inputs are again valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/PhoneNumberField.js b/src/formio/components/PhoneNumberField.js index 45ee22701..409afd3fa 100644 --- a/src/formio/components/PhoneNumberField.js +++ b/src/formio/components/PhoneNumberField.js @@ -1,5 +1,6 @@ import {Formio} from 'formiojs'; +import {setErrorAttributes} from '../utils'; import enableValidationPlugins from '../validators/plugins'; const PhoneNumber = Formio.Components.components.phoneNumber; @@ -44,6 +45,15 @@ class PhoneNumberField extends PhoneNumber { return info; } + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + // setErrorAttributes cannot be done for a `multiple` component + // https://github.com/open-formulieren/open-forms-sdk/pull/717#issuecomment-2405060364 + if (!this.component.multiple) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + } + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } + checkComponentValidity(data, dirty, row, options = {}) { let updatedOptions = {...options}; if (this.component.validate.plugins && this.component.validate.plugins.length) { diff --git a/src/formio/components/Radio.js b/src/formio/components/Radio.js index cd174a4d7..796831a79 100644 --- a/src/formio/components/Radio.js +++ b/src/formio/components/Radio.js @@ -1,5 +1,7 @@ import {Formio} from 'react-formio'; +import {setErrorAttributes} from '../utils'; + /** * Extend the default radio field to modify it to our needs. */ @@ -14,6 +16,11 @@ class Radio extends Formio.Components.components.radio { ].join(' '); return info; } + + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } } export default Radio; diff --git a/src/formio/components/Select.js b/src/formio/components/Select.js index 468a10f35..51c9db0aa 100644 --- a/src/formio/components/Select.js +++ b/src/formio/components/Select.js @@ -1,7 +1,7 @@ import isObject from 'lodash/isObject'; import {Formio} from 'react-formio'; -import {applyPrefix} from '../utils'; +import {applyPrefix, setErrorAttributes} from '../utils'; /** * Extend the default select field to modify it to our needs. @@ -16,6 +16,13 @@ class Select extends Formio.Components.components.select { return info; } + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + const inputClone = this.element.querySelector('input:not([type="hidden"])'); + const targetElements = inputClone ? [inputClone] : []; + setErrorAttributes(targetElements, hasErrors, hasMessages, this.refs.messageContainer.id); + return super.setErrorClasses(targetElements, dirty, hasErrors, hasMessages); + } + setValue(value, flags = {}) { // check if it's an appointment config field if (this.component?.appointments != null) { diff --git a/src/formio/components/Selectboxes.js b/src/formio/components/Selectboxes.js index 094376add..1cce451b7 100644 --- a/src/formio/components/Selectboxes.js +++ b/src/formio/components/Selectboxes.js @@ -1,5 +1,6 @@ import {Formio} from 'react-formio'; +import {setErrorAttributes} from '../utils'; import './Checkbox.scss'; /** @@ -17,6 +18,11 @@ class Selectboxes extends Formio.Components.components.selectboxes { ].join(' '); return info; } + + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } } export default Selectboxes; diff --git a/src/formio/components/Selectboxes.spec.js b/src/formio/components/Selectboxes.spec.js new file mode 100644 index 000000000..b3a85ce41 --- /dev/null +++ b/src/formio/components/Selectboxes.spec.js @@ -0,0 +1,95 @@ +import {expect} from '@storybook/test'; +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const selectboxesForm = { + type: 'form', + components: [ + { + key: 'selectboxes', + type: 'selectboxes', + label: 'Selectboxes', + values: [ + { + label: 'Optie A', + value: 'selectA', + }, + { + label: 'Optie B', + value: 'selectB', + }, + ], + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async () => { + let formJSON = _.cloneDeep(selectboxesForm); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The selectboxes component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Selectboxes component checked', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const selectboxA = screen.getByLabelText('Optie A'); + const selectboxB = screen.getByLabelText('Optie B'); + + expect(selectboxA).toBeVisible(); + expect(selectboxB).toBeVisible(); + + await user.click(selectboxA); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Selectboxes component without checked selectbox', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(); + + const selectboxA = screen.getByLabelText('Optie A'); + const selectboxB = screen.getByLabelText('Optie B'); + + // Check and uncheck selectboxA to trigger the validation + await user.click(selectboxA); + await user.click(selectboxA); + + // All selectboxes are marked as invalid and have aria-describedby and aria-invalid + expect(selectboxA).toHaveClass('is-invalid'); + expect(selectboxA).toHaveAttribute('aria-describedby'); + expect(selectboxA).toHaveAttribute('aria-invalid', 'true'); + expect(selectboxB).toHaveClass('is-invalid'); + expect(selectboxB).toHaveAttribute('aria-describedby'); + expect(selectboxB).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + await user.click(selectboxB); + + // All checkboxes are again marked as valid and without aria-describedby and aria-invalid + expect(selectboxA).not.toHaveClass('is-invalid'); + expect(selectboxA).not.toHaveAttribute('aria-describedby'); + expect(selectboxA).not.toHaveAttribute('aria-invalid'); + expect(selectboxB).not.toHaveClass('is-invalid'); + expect(selectboxB).not.toHaveAttribute('aria-describedby'); + expect(selectboxB).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/TextArea.js b/src/formio/components/TextArea.js index 1d66d1b75..1bdc77147 100644 --- a/src/formio/components/TextArea.js +++ b/src/formio/components/TextArea.js @@ -1,6 +1,6 @@ import {Formio} from 'react-formio'; -import {escapeHtml} from '../utils'; +import {escapeHtml, setErrorAttributes} from '../utils'; /** * Extend the default text field to modify it to our needs. @@ -17,6 +17,15 @@ class TextArea extends Formio.Components.components.textarea { return info; } + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + // setErrorAttributes cannot be done for a `multiple` component + // https://github.com/open-formulieren/open-forms-sdk/pull/717#issuecomment-2405060364 + if (!this.component.multiple) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + } + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } + renderElement(value, index) { // security issue #19 - self XSS if the contents are not escaped and formio ends // up rendering the unsanitized content. As a workaround, we apply the escaping diff --git a/src/formio/components/TextField.js b/src/formio/components/TextField.js index c24e0e1cc..86c513e1b 100644 --- a/src/formio/components/TextField.js +++ b/src/formio/components/TextField.js @@ -2,6 +2,7 @@ import debounce from 'lodash/debounce'; import {Formio} from 'react-formio'; import {get} from '../../api'; +import {setErrorAttributes} from '../utils'; import enableValidationPlugins from '../validators/plugins'; const POSTCODE_REGEX = /^[0-9]{4}\s?[a-zA-Z]{2}$/; @@ -37,6 +38,15 @@ class TextField extends Formio.Components.components.textfield { return super.checkComponentValidity(data, dirty, row, updatedOptions); } + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + // setErrorAttributes cannot be done for a `multiple` component + // https://github.com/open-formulieren/open-forms-sdk/pull/717#issuecomment-2405060364 + if (!this.component.multiple) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + } + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } + /** * Return a debounced method to look up and autocomplete the location data. */ diff --git a/src/formio/components/TextField.spec.js b/src/formio/components/TextField.spec.js new file mode 100644 index 000000000..3d0e35579 --- /dev/null +++ b/src/formio/components/TextField.spec.js @@ -0,0 +1,187 @@ +import {expect} from '@storybook/test'; +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const textfieldForm = { + type: 'form', + components: [ + { + key: 'textfield-single', + type: 'textfield', + label: 'Text', + validate: { + required: true, + }, + }, + ], +}; + +const multipleTextfieldForm = { + type: 'form', + components: [ + { + key: 'textfield-multiple', + type: 'textfield', + label: 'Text list', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The text component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single textfield component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(textfieldForm); + + const input = screen.getByLabelText('Text'); + + expect(input).toBeVisible(); + + await user.type(input, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Single textfield component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(textfieldForm); + + 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).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + 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(); + }); +}); + +describe('The mutiple text component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple textfield component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextfieldForm); + + const multipleInput = screen.getByLabelText('Text list'); + + expect(multipleInput).toBeVisible(); + + await user.type(multipleInput, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple textfield component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextfieldForm); + + const multipleInput = screen.getByLabelText('Text list'); + + // Trigger validation + await user.type(multipleInput, 'foo'); + await user.clear(multipleInput); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(multipleInput).toHaveClass('is-invalid'); + expect(multipleInput).not.toHaveAttribute('aria-describedby'); + expect(multipleInput).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple textfield without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextfieldForm); + + const multipleInput = screen.getByLabelText('Text list'); + const multipleTextComponent = getComponentNode(multipleInput); + + // Trigger validation + await user.type(multipleInput, 'foo'); + await user.clear(multipleInput); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(multipleTextComponent).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple textfield with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextfieldForm); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], 'foo'); + await user.type(inputs[1], 'bar'); + await user.tab({shift: true}); + + // The Both inputs are valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + + await user.clear(inputs[0]); + await user.tab({shift: true}); + + // Both inputs are now marked as invalid + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(inputs[0], 'foo bar'); + await user.tab({shift: true}); + + // Both inputs are again valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/Textarea.spec.js b/src/formio/components/Textarea.spec.js new file mode 100644 index 000000000..0136c7c90 --- /dev/null +++ b/src/formio/components/Textarea.spec.js @@ -0,0 +1,193 @@ +import {expect} from '@storybook/test'; +import {screen} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import _ from 'lodash'; +import {Formio} from 'react-formio'; + +import {getComponentNode} from 'formio/components/jest-util'; +import OpenFormsModule from 'formio/module'; + +// Use our custom components +Formio.use(OpenFormsModule); + +const textareaForm = { + type: 'form', + components: [ + { + key: 'textarea-single', + type: 'textarea', + label: 'Textarea', + validate: { + required: true, + }, + }, + ], +}; + +const multipleTextareaForm = { + type: 'form', + components: [ + { + key: 'textarea-multiple', + type: 'textarea', + label: 'Multiple textarea', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); + const container = document.createElement('div'); + document.body.appendChild(container); + const form = await Formio.createForm(container, formJSON); + return {form, container}; +}; + +describe('The textarea component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Single textarea component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(textareaForm); + + const input = screen.getByLabelText('Textarea'); + + expect(input).toBeVisible(); + + await user.type(input, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Single textarea component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(textareaForm); + + const input = screen.getByLabelText('Textarea'); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // Input is invalid and should have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + 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(); + }); +}); + +describe('The multiple textarea component', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple textarea component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextareaForm); + + const input = screen.getByLabelText('Multiple textarea'); + + expect(input).toBeVisible(); + + await user.type(input, 'foo'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple textfield component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextareaForm); + + const input = screen.getByLabelText('Multiple textarea'); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(input, 'foo'); + await user.tab({shift: true}); + + // The field is again valid + expect(input).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple textfield without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextareaForm); + + const input = screen.getByLabelText('Multiple textarea'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, 'foo'); + await user.clear(input); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple textfield with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTextareaForm); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], 'foo'); + await user.type(inputs[1], 'bar'); + await user.tab({shift: true}); + + // The Both inputs are valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + + await user.clear(inputs[0]); + await user.tab({shift: true}); + + // Both inputs are now marked as invalid + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.type(inputs[0], 'foo bar'); + await user.tab({shift: true}); + + // Both inputs are again valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); +}); diff --git a/src/formio/components/TimeField.js b/src/formio/components/TimeField.js index bcf37a46c..252f66e08 100644 --- a/src/formio/components/TimeField.js +++ b/src/formio/components/TimeField.js @@ -2,6 +2,8 @@ import {Formio} from 'react-formio'; import MinMaxTimeValidator from 'formio/validators/MinMaxTimeValidator'; +import {setErrorAttributes} from '../utils'; + const Time = Formio.Components.components.time; class TimeField extends Time { @@ -30,6 +32,15 @@ class TimeField extends Time { return info; } + setErrorClasses(elements, dirty, hasErrors, hasMessages) { + // setErrorAttributes cannot be done for a `multiple` component + // https://github.com/open-formulieren/open-forms-sdk/pull/717#issuecomment-2405060364 + if (!this.component.multiple) { + setErrorAttributes(elements, hasErrors, hasMessages, this.refs.messageContainer.id); + } + return super.setErrorClasses(elements, dirty, hasErrors, hasMessages); + } + getStringAsValue(value) { const result = super.getStringAsValue(value); if (result === 'Invalid date') return value; diff --git a/src/formio/components/TimeField.spec.js b/src/formio/components/TimeField.spec.js index 9aee5345b..b20b53196 100644 --- a/src/formio/components/TimeField.spec.js +++ b/src/formio/components/TimeField.spec.js @@ -1,14 +1,16 @@ -import {screen, waitFor} from '@testing-library/dom'; +import {expect} from '@storybook/test'; +import {screen} from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import _ from 'lodash'; import {Formio} from 'react-formio'; +import {getComponentNode} from 'formio/components/jest-util'; import OpenFormsModule from 'formio/module'; // Use our custom components Formio.use(OpenFormsModule); -const phoneForm = { +const timeForm = { type: 'form', components: [ { @@ -21,8 +23,25 @@ const phoneForm = { ], }; -const renderForm = async () => { - let formJSON = _.cloneDeep(phoneForm); +const multipleTimeForm = { + type: 'form', + components: [ + { + key: 'time', + type: 'time', + input: true, + label: 'Time', + inputType: 'text', + multiple: true, + validate: { + required: true, + }, + }, + ], +}; + +const renderForm = async formConfig => { + let formJSON = _.cloneDeep(formConfig); const container = document.createElement('div'); document.body.appendChild(container); const form = await Formio.createForm(container, formJSON); @@ -36,7 +55,7 @@ describe('The time component', () => { test('Time component with invalid time', async () => { const user = userEvent.setup({delay: 50}); - const {form} = await renderForm(); + const {form} = await renderForm(timeForm); const input = screen.getByLabelText('Time'); expect(input).toBeVisible(); @@ -45,4 +64,129 @@ describe('The time component', () => { expect(form.isValid()).toBe(false); }); + + test('Time component with invalid time has descriptive aria tags', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(timeForm); + + const input = screen.getByLabelText('Time'); + expect(input).toBeVisible(); + + // Valid time input + await user.type(input, '12:00'); + expect(form.isValid()).toBeTruthy(); + + // Invalid time input + await user.clear(input); + await user.type(input, '25:00'); + + // Expect the invalid time input to have aria-describedby and aria-invalid + expect(input).toHaveClass('is-invalid'); + expect(input).toHaveAttribute('aria-describedby'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(form.isValid()).toBeFalsy(); + + // Change time input to a valid time + await user.clear(input); + await user.type(input, '12:00'); + + // Expect the valid time input to not have 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(); + }); +}); + +describe('The time component multiple', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + test('Multiple time component with valid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTimeForm); + + const multipleInput = screen.getByLabelText('Time'); + + expect(multipleInput).toBeVisible(); + + await user.type(multipleInput, '12:00'); + + expect(form.isValid()).toBeTruthy(); + }); + + test('Multiple time component with invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTimeForm); + + const input = screen.getByLabelText('Time'); + + // Trigger validation + await user.type(input, '25:00'); + await user.tab({shift: true}); + + // The field is invalid, and shouldn't have the aria-describedby or aria-invalid tags + expect(input).toHaveClass('is-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + + expect(form.isValid()).toBeFalsy(); + }); + + test('Required multiple time without inputs', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTimeForm); + + const input = screen.getByLabelText('Time'); + const component = getComponentNode(input); + + // Trigger validation + await user.type(input, '25:00'); + await user.tab({shift: true}); + + // Remove input + const [removeButton] = screen.getAllByRole('button'); + await user.click(removeButton); + + expect(component).toHaveClass('has-error'); + expect(form.isValid()).toBeFalsy(); + }); + + test('Multiple time with one valid and one invalid input', async () => { + const user = userEvent.setup({delay: 50}); + const {form} = await renderForm(multipleTimeForm); + + await user.click(screen.getByRole('button', {name: 'Add Another'})); + + const inputs = screen.getAllByRole('textbox'); + expect(inputs).toHaveLength(2); + + await user.type(inputs[0], '12:00'); + await user.type(inputs[1], '12:00'); + await user.tab({shift: true}); + + // The Both inputs are valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + + await user.clear(inputs[0]); + await user.type(inputs[0], '25:00'); + await user.tab({shift: true}); + + // Both inputs are now marked as invalid + expect(inputs[0]).toHaveClass('is-invalid'); + expect(inputs[1]).toHaveClass('is-invalid'); + expect(form.isValid()).toBeFalsy(); + + await user.clear(inputs[0]); + await user.type(inputs[0], '12:00'); + await user.tab({shift: true}); + + // Both inputs are again valid + expect(inputs[0]).not.toHaveClass('is-invalid'); + expect(inputs[1]).not.toHaveClass('is-invalid'); + expect(form.isValid()).toBeTruthy(); + }); }); diff --git a/src/formio/components/jest-util.js b/src/formio/components/jest-util.js new file mode 100644 index 000000000..2da3ff149 --- /dev/null +++ b/src/formio/components/jest-util.js @@ -0,0 +1,5 @@ +const getComponentNode = element => element.closest('[ref="component"]'); + +const getAllChildInputs = element => element.querySelectorAll('input'); + +export {getAllChildInputs, getComponentNode}; diff --git a/src/formio/templates/field.ejs b/src/formio/templates/field.ejs index bced410d0..dc3be2200 100644 --- a/src/formio/templates/field.ejs +++ b/src/formio/templates/field.ejs @@ -34,7 +34,7 @@ ref="messageContainer" class="utrecht-form-field-description utrecht-form-field-description--invalid utrecht-form-field-description--openforms-errors" role="alert" - aria-describedby="{{ctx.id}}"> + id="{{ctx.instance.id}}-{{ctx.component.key}}-error-message"> @@ -73,6 +73,6 @@ ref="messageContainer" class="utrecht-form-field-description utrecht-form-field-description--invalid utrecht-form-field-description--openforms-errors" role="alert" - aria-describedby="{{ctx.id}}"> + id="{{ctx.instance.id}}-{{ctx.component.key}}-error-message"> {% } %} diff --git a/src/formio/templates/file.ejs b/src/formio/templates/file.ejs index 5642a1574..68a4d8dd9 100644 --- a/src/formio/templates/file.ejs +++ b/src/formio/templates/file.ejs @@ -6,10 +6,10 @@ {% if (!ctx.disabled) { %}
{% } %} -
{{ctx.t('File Name')}}
-
{{ctx.t('Size')}}
+
{{ctx.t('File Name')}}
+
{{ctx.t('Size')}}
{% if (ctx.self.hasTypes) { %} -
{{ctx.t('Type')}}
+
{{ctx.t('Type')}}
{% } %} @@ -19,30 +19,34 @@ {% if (!ctx.disabled) { %}
{% } %} -
+
{% if (ctx.component.uploadOnly) { %} {{{file.originalName || file.name}}} {% } else { %} {{{file.originalName || file.name}}} {% } %}
-
{{ctx.fileSize(file.size)}}
- {% if (ctx.self.hasTypes && !ctx.disabled) { %} -
- +
+ {{ctx.fileSize(file.size)}} +
+ {% if (ctx.self.hasTypes) { %} +
+ {% if (!ctx.disabled) { %} + + {% } else { %} + {{file.fileType}} + {% } %}
{% } %} - {% if (ctx.self.hasTypes && ctx.disabled) { %} -
{{file.fileType}}
- {% } %}
{% }) %} @@ -54,7 +58,10 @@ {{{file.originalName || file.name}}} {% if (!ctx.disabled) { %} - + {% } %}
@@ -64,12 +71,12 @@ {% if (!ctx.disabled && ((ctx.component.multiple && (!ctx.component.maxNumberOfFiles || ctx.files.length < ctx.component.maxNumberOfFiles)) || !ctx.files.length)) { %} {% if (ctx.self.useWebViewCamera) { %}
- - + +
{% } else if (!ctx.self.cameraMode) { %}
- + {% if (ctx.component.multiple) { %} {{ctx.t('Drop files,')}} @@ -78,12 +85,12 @@ {% } %} {% if (ctx.self.imageUpload) { %} - {{ctx.t('Use Camera,')}} + {{ctx.t('Use Camera,')}} {% } %} {{ctx.t('or')}} - {{ctx.t('browse')}} + {{ctx.t('browse')}} {% if (ctx.component.multiple) { %} {{ctx.t('to attach files.')}} @@ -99,7 +106,7 @@
- + {% } %} {% } %} @@ -111,7 +118,10 @@
{% if (status.status === 'progress') { %} diff --git a/src/formio/templates/map.ejs b/src/formio/templates/map.ejs index 61fb69609..8aefa0c9b 100644 --- a/src/formio/templates/map.ejs +++ b/src/formio/templates/map.ejs @@ -1 +1 @@ -
+
diff --git a/src/formio/templates/multiValueRow.ejs b/src/formio/templates/multiValueRow.ejs index b8e58b3d3..9d1eaaa0d 100644 --- a/src/formio/templates/multiValueRow.ejs +++ b/src/formio/templates/multiValueRow.ejs @@ -5,7 +5,8 @@ {% if (!ctx.disabled) { %} {% } %} diff --git a/src/formio/utils.js b/src/formio/utils.js index ec3038424..f91e819b6 100644 --- a/src/formio/utils.js +++ b/src/formio/utils.js @@ -14,4 +14,35 @@ const escapeHtml = source => { return pre.innerHTML.replace(/"/g, '"').replace(/'/g, ''').replace(/&/g, '&'); }; -export {applyPrefix, escapeHtml}; +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); + + if (hasErrors && hasMessages && !ariaDescriptions.includes(messageContainerId)) { + // The input has an error, but the error message isn't yet part of the ariaDescriptions + ariaDescriptions.push(messageContainerId); + } + + if (!hasErrors && ariaDescriptions.includes(messageContainerId)) { + // The input doesn't have an error, but the error message is still a part of the ariaDescriptions + ariaDescriptions = ariaDescriptions.filter(description => description !== messageContainerId); + } + + if (ariaDescriptions.length > 0) { + element.setAttribute('aria-describedby', ariaDescriptions.join(' ')); + } else { + element.removeAttribute('aria-describedby'); + } + + if (hasErrors) { + element.setAttribute('aria-invalid', 'true'); + } else { + element.removeAttribute('aria-invalid'); + } + }); +}; + +export {applyPrefix, escapeHtml, setErrorAttributes}; diff --git a/src/scss/components/_file-upload.scss b/src/scss/components/_file-upload.scss index 9d669a0ab..01892f27e 100644 --- a/src/scss/components/_file-upload.scss +++ b/src/scss/components/_file-upload.scss @@ -126,6 +126,7 @@ we don't have strict BEM naming here. margin-left: 0.4em; opacity: 0.6; transition: all 0.4s ease; + color: $color-black; &:hover { opacity: 1; diff --git a/src/scss/components/_typography.scss b/src/scss/components/_typography.scss index ab6cfafdd..e93421c75 100644 --- a/src/scss/components/_typography.scss +++ b/src/scss/components/_typography.scss @@ -20,6 +20,12 @@ @include h2(auto); } +.#{prefix('content')} { + @include bem.modifier('bold') { + font-weight: 700; + } +} + .#{prefix(body)} { @include body(auto); clear: both;