Skip to content

Commit

Permalink
feat(a11y): fields announce their descriptions and labels properly (#…
Browse files Browse the repository at this point in the history
…1043)

* Include all description blocks in `aria-describedby`.
* Add `required` property to inputs that can be required.
* Add `aria-invalid` property to input which have validation errors.
* Do not announce the asterisk/star which denotes a required field.
* Have components generate their own error ID.
* Remove confusing use of `id` in Label. It now has an `id` and an `htmlFor` prop.
* Update groups `aria-labelledby` to point to an actual label.
  • Loading branch information
douglasbouttell-camunda authored Feb 15, 2024
1 parent c93e597 commit 1cd29d8
Show file tree
Hide file tree
Showing 16 changed files with 87 additions and 54 deletions.
4 changes: 2 additions & 2 deletions packages/form-js-viewer/src/render/components/Description.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { useSingleLineTemplateEvaluation } from '../hooks';


export function Description(props) {
const { description } = props;
const { description, id } = props;

const evaluatedDescription = useSingleLineTemplateEvaluation(description || '', { debug: true });

if (!evaluatedDescription) {
return null;
}

return <div class="fjs-form-field-description">{ evaluatedDescription }</div>;
return <div id={ id } class="fjs-form-field-description">{ evaluatedDescription }</div>;
}
2 changes: 0 additions & 2 deletions packages/form-js-viewer/src/render/components/FormField.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ export function FormField(props) {

const domId = `${prefixId(field.id, formId, indexes)}`;
const fieldErrors = get(errors, [ field.id, ...Object.values(indexes || {}) ]) || [];
const errorMessageId = errors.length === 0 ? undefined : `${domId}-error-message`;

return (
<Column field={ field } class={ gridColumnClasses(field) }>
Expand All @@ -137,7 +136,6 @@ export function FormField(props) {
{ ...props }
disabled={ disabled }
errors={ fieldErrors }
errorMessageId={ errorMessageId }
domId={ domId }
onChange={ disabled || readonly ? noop : onChangeIndexed }
onBlur={ disabled || readonly ? noop : onBlur }
Expand Down
8 changes: 5 additions & 3 deletions packages/form-js-viewer/src/render/components/Label.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { useSingleLineTemplateEvaluation } from '../hooks';

/**
* @typedef Props
* @property {string} [id]
* @property {string|undefined} [id]
* @property {string|undefined} [htmlFor]
* @property {string|undefined} label
* @property {string} [class]
* @property {boolean} [collapseOnEmpty]
Expand All @@ -18,6 +19,7 @@ import { useSingleLineTemplateEvaluation } from '../hooks';
export function Label(props) {
const {
id,
htmlFor,
label,
collapseOnEmpty = true,
required = false
Expand All @@ -26,11 +28,11 @@ export function Label(props) {
const evaluatedLabel = useSingleLineTemplateEvaluation(label || '', { debug: true });

return (
<label for={ id } class={ classNames('fjs-form-field-label', { 'fjs-incollapsible-label': !collapseOnEmpty }, props['class']) }>
<label id={ id } for={ htmlFor } class={ classNames('fjs-form-field-label', { 'fjs-incollapsible-label': !collapseOnEmpty }, props['class']) }>
{ props.children }
{ evaluatedLabel }
{
required && <span class="fjs-asterix">*</span>
required && <span class="fjs-asterix" aria-hidden>*</span>
}
</label>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export function Checkbox(props) {
const {
disabled,
errors = [],
errorMessageId,
domId,
onBlur,
onFocus,
Expand All @@ -38,9 +37,12 @@ export function Checkbox(props) {
});
};

const descriptionId = `${domId}-description`;
const errorMessageId = `${domId}-error-message`;

return <div class={ classNames(formFieldClasses(type, { errors, disabled, readonly }), { 'fjs-checked': value }) }>
<Label
id={ domId }
htmlFor={ domId }
label={ label }
required={ required }>
<input
Expand All @@ -53,10 +55,12 @@ export function Checkbox(props) {
onChange={ onChange }
onBlur={ () => onBlur && onBlur() }
onFocus={ () => onFocus && onFocus() }
aria-describedby={ errorMessageId } />
required={ required }
aria-invalid={ errors.length > 0 }
aria-describedby={ [ descriptionId, errorMessageId ].join(' ') } />
</Label>
<Description description={ description } />
<Errors errors={ errors } id={ errorMessageId } />
<Description id={ descriptionId } description={ description } />
<Errors id={ errorMessageId } errors={ errors } />
</div>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export function Checklist(props) {
const {
disabled,
errors = [],
errorMessageId,
domId,
onBlur,
onFocus,
Expand Down Expand Up @@ -85,6 +84,9 @@ export function Checklist(props) {
onChange: props.onChange
});

const descriptionId = `${domId}-description`;
const errorMessageId = `${domId}-error-message`;

return <div class={ classNames(formFieldClasses(type, { errors, disabled, readonly })) } ref={ outerDivRef }>
<Label
label={ label }
Expand All @@ -97,7 +99,7 @@ export function Checklist(props) {

return (
<Label
id={ itemDomId }
htmlFor={ itemDomId }
label={ o.label }
class={ classNames({
'fjs-checked': isChecked
Expand All @@ -113,13 +115,15 @@ export function Checklist(props) {
onClick={ () => toggleCheckbox(o.value) }
onBlur={ onCheckboxBlur }
onFocus={ onCheckboxFocus }
aria-describedby={ errorMessageId } />
required={ required }
aria-invalid={ errors.length > 0 }
aria-describedby={ [ descriptionId, errorMessageId ].join(' ') } />
</Label>
);
})
}
<Description description={ description } />
<Errors errors={ errors } id={ errorMessageId } />
<Description id={ descriptionId } description={ description } />
<Errors id={ errorMessageId } errors={ errors } />
</div>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export function Datetime(props) {
}, []);

const errorMessageId = allErrors.length === 0 ? undefined : `${prefixId(id, formId)}-error-message`;
const descriptionId = `${prefixId(id, formId)}-description`;

const datePickerProps = {
label: dateLabel,
Expand All @@ -163,7 +164,7 @@ export function Datetime(props) {
date: dateTime.date,
readonly,
setDate,
'aria-describedby': errorMessageId
'aria-describedby': [ descriptionId, errorMessageId ].join(' ')
};

const timePickerProps = {
Expand All @@ -179,7 +180,7 @@ export function Datetime(props) {
timeInterval,
time: dateTime.time,
setTime,
'aria-describedby': errorMessageId
'aria-describedby': [ descriptionId, errorMessageId ].join(' ')
};

return <div class={ formFieldClasses(type, { errors: allErrors, disabled, readonly }) }>
Expand All @@ -188,7 +189,7 @@ export function Datetime(props) {
{ useTimePicker && useDatePicker && <div class="fjs-datetime-separator" /> }
{ useTimePicker && <Timepicker { ...timePickerProps } /> }
</div>
<Description description={ description } />
<Description id={ descriptionId } description={ description } />
<Errors errors={ allErrors } id={ errorMessageId } />
</div>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function IFrame(props) {
}, [ sandbox, allow ]);

return <div class={ formFieldClasses(type, { disabled, readonly }) }>
<Label id={ domId } label={ evaluatedLabel } />
<Label htmlFor={ domId } label={ evaluatedLabel } />
{
!evaluatedUrl && <IFramePlaceholder text="No content to show." />
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export function Numberfield(props) {
const {
disabled,
errors = [],
errorMessageId,
domId,
onBlur,
onFocus,
Expand Down Expand Up @@ -190,9 +189,12 @@ export function Numberfield(props) {
}
};

const descriptionId = `${domId}-description`;
const errorMessageId = `${domId}-error-message`;

return <div class={ formFieldClasses(type, { errors, disabled, readonly }) }>
<Label
id={ domId }
htmlFor={ domId }
label={ label }
required={ required } />
<TemplatedInputAdorner disabled={ disabled } readonly={ readonly } pre={ prefixAdorner } post={ suffixAdorner }>
Expand All @@ -215,7 +217,9 @@ export function Numberfield(props) {
autoComplete="off"
step={ incrementAmount }
value={ displayValue }
aria-describedby={ errorMessageId } />
aria-describedby={ [ descriptionId, errorMessageId ].join(' ') }
required={ required }
aria-invalid={ errors.length > 0 } />
<div class={ classNames('fjs-number-arrow-container', { 'fjs-disabled': disabled, 'fjs-readonly': readonly }) }>
{ /* we're disabling tab navigation on both buttons to imitate the native browser behavior of input[type='number'] increment arrows */ }
<button
Expand All @@ -234,8 +238,8 @@ export function Numberfield(props) {
</div>
</div>
</TemplatedInputAdorner>
<Description description={ description } />
<Errors errors={ errors } id={ errorMessageId } />
<Description id={ descriptionId } description={ description } />
<Errors id={ errorMessageId } errors={ errors } />
</div>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export function Radio(props) {
const {
disabled,
errors = [],
errorMessageId,
domId,
onBlur,
onFocus,
Expand Down Expand Up @@ -78,6 +77,9 @@ export function Radio(props) {
onChange: props.onChange
});

const descriptionId = `${domId}-description`;
const errorMessageId = `${domId}-error-message`;

return <div class={ formFieldClasses(type, { errors, disabled, readonly }) } ref={ outerDivRef }>
<Label
label={ label }
Expand All @@ -90,7 +92,7 @@ export function Radio(props) {

return (
<Label
id={ itemDomId }
htmlFor={ itemDomId }
key={ index }
label={ option.label }
class={ classNames({ 'fjs-checked': isChecked }) }
Expand All @@ -105,13 +107,15 @@ export function Radio(props) {
onClick={ () => onChange(option.value) }
onBlur={ onRadioBlur }
onFocus={ onRadioFocus }
aria-describedby={ errorMessageId } />
aria-describedby={ [ descriptionId, errorMessageId ].join(' ') }
required={ required }
aria-invalid={ errors.length > 0 } />
</Label>
);
})
}
<Description description={ description } />
<Errors errors={ errors } id={ errorMessageId } />
<Description id={ descriptionId } description={ description } />
<Errors id={ errorMessageId } errors={ errors } />
</div>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export function Select(props) {
const {
disabled,
errors = [],
errorMessageId,
domId,
onBlur,
onFocus,
Expand All @@ -33,6 +32,9 @@ export function Select(props) {

const { required } = validate;

const descriptionId = `${domId}-description`;
const errorMessageId = `${domId}-error-message`;

const selectProps = {
domId,
disabled,
Expand All @@ -43,7 +45,9 @@ export function Select(props) {
value,
onChange,
readonly,
'aria-describedby': errorMessageId,
required,
'aria-invalid': errors.length > 0,
'aria-describedby': [ descriptionId, errorMessageId ].join(' '),
};

return <div
Expand All @@ -58,12 +62,12 @@ export function Select(props) {
}
>
<Label
id={ domId }
htmlFor={ domId }
label={ label }
required={ required } />
{ searchable ? <SearchableSelect { ...selectProps } /> : <SimpleSelect { ...selectProps } /> }
<Description description={ description } />
<Errors errors={ errors } id={ errorMessageId } />
<Description id={ descriptionId } description={ description } />
<Errors id={ errorMessageId } errors={ errors } />
</div>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function Table(props) {

return (
<div class={ formFieldClasses(type) }>
<Label id={ prefixId(id) } label={ label } />
<Label htmlFor={ prefixId(id) } label={ label } />
<div
class={ classNames('fjs-table-middle-container', {
'fjs-table-empty': evaluatedColumns.length === 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export function Taglist(props) {
const {
disabled,
errors = [],
errorMessageId,
onFocus,
domId,
onBlur,
Expand Down Expand Up @@ -177,6 +176,9 @@ export function Taglist(props) {

const shouldDisplayDropdown = useMemo(() => !disabled && loadState === LOAD_STATES.LOADED && isDropdownExpanded && !isEscapeClosed, [ disabled, isDropdownExpanded, isEscapeClosed, loadState ]);

const descriptionId = `${domId}-description`;
const errorMessageId = `${domId}-error-message`;

return <div
ref={ focusScopeRef }
class={ formFieldClasses(type, { errors, disabled, readonly }) }
Expand All @@ -192,7 +194,7 @@ export function Taglist(props) {
<Label
label={ label }
required={ required }
id={ domId } />
htmlFor={ domId } />
{ (!disabled && !readonly && !!values.length) && <SkipLink className="fjs-taglist-skip-link" label="Skip to search" onSkip={ onSkipToSearch } /> }
<div class={ classNames('fjs-taglist', { 'fjs-disabled': disabled, 'fjs-readonly': readonly }) }>
{ loadState === LOAD_STATES.LOADED &&
Expand Down Expand Up @@ -235,7 +237,9 @@ export function Taglist(props) {
onMouseDown={ () => setIsEscapeClose(false) }
onFocus={ onInputFocus }
onBlur={ onInputBlur }
aria-describedby={ errorMessageId } />
aria-describedby={ [ descriptionId, errorMessageId ].join(' ') }
required={ required }
aria-invalid={ errors.length > 0 } />
</div>
<div class="fjs-taglist-anchor">
{ shouldDisplayDropdown && <DropdownList
Expand All @@ -245,8 +249,8 @@ export function Taglist(props) {
emptyListMessage={ hasOptionsLeft ? 'No results' : 'All values selected' }
listenerElement={ inputRef.current } /> }
</div>
<Description description={ description } />
<Errors errors={ errors } id={ errorMessageId } />
<Description id={ descriptionId } description={ description } />
<Errors id={ errorMessageId } errors={ errors } />
</div>;
}

Expand Down
Loading

0 comments on commit 1cd29d8

Please sign in to comment.