diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7af0a133..d8cfe838 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## v11.0.0
+### Accessibility
+
+- Resolved orphaned form labels issue (Criteria 3.3.2).
+
## v10.2.0
### Breaking Changes
diff --git a/src/components/record/Field.jsx b/src/components/record/Field.jsx
index 77926691..7a51e526 100644
--- a/src/components/record/Field.jsx
+++ b/src/components/record/Field.jsx
@@ -21,6 +21,7 @@ import {
DateInput,
StructuredDateInput,
} from '../../helpers/configContextInputs';
+import buildInputId from '../../helpers/inputIdHelper';
const {
getPath,
@@ -166,6 +167,13 @@ export default function Field(props, context) {
const basePropTypes = BaseComponent.propTypes;
Object.keys(props).forEach((propName) => {
+ // Skip id: Field always computes its own id from the data path, so a propagated
+ // id (from a parent CustomCompoundInput's decorateInputs) must not override
+ // it — that would make the input id not match with label's htmlFor.
+ if (propName === 'id') {
+ return;
+ }
+
if (propName in basePropTypes) {
// eslint-disable-next-line react/destructuring-assignment
providedProps[propName] = props[propName];
@@ -186,12 +194,17 @@ export default function Field(props, context) {
const effectiveReadOnly = providedProps.readOnly || isFieldViewReadOnly(computeContext);
const computedProps = {};
+ const inputId = buildInputId(recordType, formName, fullPath);
+ computedProps.id = inputId;
+
if (fieldConfig.repeating && viewType !== 'search') {
computedProps.repeating = fieldConfig.repeating;
}
if ('label' in basePropTypes) {
computedProps.label = renderLabel(field, labelMessage, computeContext, {
+ htmlFor: inputId,
+ id: `${inputId}-label`,
readOnly: effectiveReadOnly,
});
}
@@ -209,6 +222,12 @@ export default function Field(props, context) {
const childName = childInput.props.name;
const childLabelMessage = childInput.props.labelMessage;
const childField = field[childName];
+ // For repeating containers (e.g. tabular repeating CompoundInput),
+ // point the label to the first input.
+ const childPath = fieldConfig.repeating
+ ? [...fullPath, '0', childName]
+ : [...fullPath, childName];
+ const childInputId = buildInputId(recordType, formName, childPath);
const childComputeContext = {
path: [...path, childName],
@@ -222,6 +241,8 @@ export default function Field(props, context) {
return (childField && renderLabel(childField, childLabelMessage, childComputeContext, {
key: childName,
+ htmlFor: childInputId,
+ id: `${childInputId}-label`,
readOnly: effectiveReadOnly,
}));
};
diff --git a/src/components/record/InputTable.jsx b/src/components/record/InputTable.jsx
index 60a1308d..c11d8a0e 100644
--- a/src/components/record/InputTable.jsx
+++ b/src/components/record/InputTable.jsx
@@ -13,6 +13,8 @@ import {
dataPathToFieldDescriptorPath,
} from '../../helpers/configHelpers';
+import buildInputId from '../../helpers/inputIdHelper';
+
const {
Label,
InputTable: BaseInputTable,
@@ -32,10 +34,11 @@ const renderTableLabel = (recordTypeConfig, name) => {
return (message ? renderMessageLabel(message) : null);
};
-const renderFieldLabel = (fieldConfig, inputProps) => {
+const renderFieldLabel = (fieldConfig, inputProps, labelProps) => {
const message = get(fieldConfig, ['messages', 'name']);
const props = {
+ ...labelProps,
readOnly: inputProps.readOnly,
required: inputProps.required,
};
@@ -60,6 +63,7 @@ const contextTypes = {
config: PropTypes.shape({
recordTypes: PropTypes.object,
}),
+ formName: PropTypes.string,
intl: intlShape,
recordType: PropTypes.string,
};
@@ -72,6 +76,7 @@ export default function InputTable(props, context) {
const {
config,
+ formName,
intl,
recordType,
} = context;
@@ -80,11 +85,21 @@ export default function InputTable(props, context) {
const fields = get(recordTypeConfig, 'fields');
const renderLabel = (input) => {
- const path = dataPathToFieldDescriptorPath(pathHelpers.getPath(input.props));
+ const fullPath = pathHelpers.getPath(input.props);
+ const path = dataPathToFieldDescriptorPath(fullPath);
const field = get(fields, path);
const fieldConfig = get(field, configKey);
- return (fieldConfig && renderFieldLabel(fieldConfig, input.props));
+ if (!fieldConfig) {
+ return null;
+ }
+
+ const inputId = buildInputId(recordType, formName, fullPath);
+
+ return renderFieldLabel(fieldConfig, input.props, {
+ htmlFor: inputId,
+ id: `${inputId}-label`,
+ });
};
const renderAriaLabel = (input) => {
diff --git a/src/components/record/TypedHierarchyEditor.jsx b/src/components/record/TypedHierarchyEditor.jsx
index 94fa4784..65d40bf8 100644
--- a/src/components/record/TypedHierarchyEditor.jsx
+++ b/src/components/record/TypedHierarchyEditor.jsx
@@ -5,10 +5,12 @@ import Immutable from 'immutable';
import { components as inputComponents } from 'cspace-input';
import MiniViewPopupAutocompleteInputContainer from '../../containers/record/MiniViewPopupAutocompleteInputContainer';
import OptionPickerInputContainer from '../../containers/record/OptionPickerInputContainer';
+import buildInputId from '../../helpers/inputIdHelper';
const {
CompoundInput,
InputTable,
+ Label,
} = inputComponents;
const propTypes = {
@@ -152,9 +154,21 @@ export class BaseTypedHierarchyEditor extends Component {
const parentRefName = value.getIn(['parent', 'refName']);
const parentType = value.getIn(['parent', 'type']);
+ const parentRefNameId = buildInputId(recordType, undefined, ['hierarchy', 'parent', 'parentRefName']);
+ const parentTypeId = buildInputId(recordType, undefined, ['hierarchy', 'parent', 'parentType']);
+
+ const renderParentLabel = (input) => {
+ const { id, label } = input.props;
+ return ;
+ };
+
return (
-
+
{
+ const { name, label } = input.props;
+ const childInputId = `${childrenTemplateId}-0-${name}`;
+
+ return ;
+ };
+
return (
diff --git a/src/components/record/UntypedHierarchyEditor.jsx b/src/components/record/UntypedHierarchyEditor.jsx
index 6cf3d81b..b926fe99 100644
--- a/src/components/record/UntypedHierarchyEditor.jsx
+++ b/src/components/record/UntypedHierarchyEditor.jsx
@@ -4,6 +4,7 @@ import { injectIntl, intlShape } from 'react-intl';
import Immutable from 'immutable';
import { components as inputComponents } from 'cspace-input';
import MiniViewPopupAutocompleteInputContainer from '../../containers/record/MiniViewPopupAutocompleteInputContainer';
+import buildInputId from '../../helpers/inputIdHelper';
const {
CompoundInput,
@@ -118,8 +119,11 @@ export class BaseUntypedHierarchyEditor extends Component {
const source = [recordType, vocabulary].join('/');
const parentRefName = value.getIn(['parent', 'refName']);
+ const parentId = buildInputId(recordType, undefined, ['hierarchy', 'parent']);
+
return (
child.get('refName'));
+ const childrenContainerId = buildInputId(recordType, undefined, ['hierarchy', 'children']);
+
return (
String(segment).replace(/[^a-zA-Z0-9_-]/g, '_');
+
+export default function buildInputId(recordType, formName, path) {
+ const parts = ['cspace'];
+
+ if (recordType) {
+ parts.push(recordType);
+ }
+
+ if (formName) {
+ parts.push(formName);
+ }
+
+ if (Array.isArray(path)) {
+ path.forEach((part) => {
+ if (part !== undefined && part !== null && part !== '') {
+ parts.push(part);
+ }
+ });
+ }
+
+ return parts.map(sanitizeIdSegment).join('-');
+}